From 15481c43eda9403e1963f1bbc5a09582c5df5d56 Mon Sep 17 00:00:00 2001 From: Dan Cohen Vaxman Date: Wed, 22 Jul 2026 09:07:16 +0200 Subject: [PATCH] test: add 52-test suite, GitHub Actions CI with bandit scan, rewrite README --- .github/workflows/ci.yml | 49 ++++++++ README.md | 264 +++++++++++++++++++++++++++++---------- tests/test_attacker.py | 233 ++++++++++++++++++++++++++++++++++ tests/test_defender.py | 159 +++++++++++++++++++++++ tests/test_node.py | 90 +++++++++++++ tests/test_simulation.py | 153 +++++++++++++++++++++++ 6 files changed, 881 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_attacker.py create mode 100644 tests/test_defender.py create mode 100644 tests/test_node.py create mode 100644 tests/test_simulation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..591effa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Lint (ruff) + run: ruff check cybersim/ tests/ run.py + + - name: Run tests + run: pytest --cov=cybersim --cov-report=term-missing + + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install bandit + run: pip install bandit + + - name: Static security analysis (bandit) + run: bandit -r cybersim/ -ll + # -ll = only report medium/high severity issues + # Low-severity noise (assert statements etc.) is excluded intentionally diff --git a/README.md b/README.md index 1b0510a..3fe11d1 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,235 @@ -# ๐Ÿ›ก๏ธ Cyber MAS Sim +# cyber-sim-mas -## Multi Agent System Simulation for CyberSecurity WF +**Adaptive multi-agent cybersecurity simulation** โ€” model real attack chains, tune detection logic, and measure what actually matters: MTTD, MTTR, and blast radius. -SentinelByte 2025 (DCV) - -A lightweight simulation framework for modeling attacker and defender agents in a cybersecurity environment. Built from scratch to support reinforcement learning, automation, and cloud security training scenarios. +![CI](https://github.com/SentinelByte/cyber-sim-mas/actions/workflows/ci.yml/badge.svg) +![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue) +![License: MIT](https://img.shields.io/badge/license-MIT-green) +--- -## ๐Ÿš€ Features +## Why this exists -- Simulated network with vulnerable nodes -- Attacker and defender agents with pluggable logic -- Logs, patching, and basic interaction tracking -- Designed for future expansion: RL, PettingZoo, AWS/K8s integrations +I built this while thinking about a question that comes up a lot in security engineering: **how do you reason about detection coverage before you're under attack?** +Most threat modeling happens on whiteboards. This simulation lets you run an attacker against a defender on a specific network topology and watch the kill chain unfold โ€” which techniques the attacker leans on, how quickly the defender picks up the signal, and what the blast radius looks like by the time containment kicks in. -## ๐Ÿ“ Project Structure +It's not a replacement for real red team exercises, but it's a fast way to prototype detection tuning, model IR response delays, or understand why a supply chain attacker can be in your environment for weeks before anything fires. -``` +--- -cyber-mas-sim/ -โ”œโ”€โ”€ agents/ # Attacker and Defender agents -โ”‚ โ”œโ”€โ”€ attacker.py -โ”‚ โ””โ”€โ”€ defender.py -โ”œโ”€โ”€ core/ # Simulation engine & utilities -โ”‚ โ”œโ”€โ”€ simulation.py -โ”‚ โ””โ”€โ”€ utils.py (optional) -โ”œโ”€โ”€ env/ # Simulated environment (nodes, network) -โ”‚ โ”œโ”€โ”€ node.py -โ”‚ โ””โ”€โ”€ network.py (optional) -โ”œโ”€โ”€ run.py # Entrypoint to run the simulation -โ”œโ”€โ”€ requirements.txt # Python dependencies -|__ README.md - -```` +## Scenarios + +Three built-in scenarios, each modeled on real threat patterns: + +| Scenario | Threat | Key characteristic | +|---|---|---| +| `lateral_movement` | Perimeter breach โ†’ internal pivot | DMZ foothold โ†’ internal recon โ†’ DC compromise | +| `cloud_iam` | Compromised identity โ†’ privilege escalation | Misconfigured IAM roles, IMDS credential theft, silent S3 exfil | +| `supply_chain` | Malicious dependency โ†’ CI/CD compromise | Dependency confusion โ†’ pipeline takeover โ†’ poisoned artifact in prod | + +### MITRE ATT&CK coverage + +| Technique ID | Name | Scenario | +|---|---|---| +| T1046 | Network Service Scanning | lateral_movement | +| T1190 | Exploit Public-Facing Application | lateral_movement | +| T1110.001 | Brute Force: Password Guessing | lateral_movement | +| T1021.001 | Remote Services: SSH | lateral_movement | +| T1547.001 | Boot or Logon Autostart | lateral_movement | +| T1048 | Exfiltration Over Alternative Protocol | lateral_movement | +| T1078.004 | Valid Accounts: Cloud Accounts | cloud_iam | +| T1580 | Cloud Infrastructure Discovery | cloud_iam | +| T1078 | Privilege Escalation via Misconfigured Role | cloud_iam | +| T1552.005 | Cloud Instance Metadata API (IMDS) | cloud_iam | +| T1530 | Data from Cloud Storage Object | cloud_iam | +| T1195.001 | Supply Chain: Software Dependencies | supply_chain | +| T1552.001 | Unsecured Credentials: CI/CD Secrets | supply_chain | +| T1072 | Software Deployment Tools | supply_chain | --- -## ๐Ÿงช Quick Start +## Quick start -### 1. Clone and Setup ```bash -git clone https://github.com/SentinelByte/cyberMasSim.git -cd cyberMasSim -python3 -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate -pip install -r requirements.txt -```` +git clone https://github.com/SentinelByte/cyber-sim-mas.git +cd cyber-sim-mas +pip install -e ".[dev]" -### 2. Run the Simulation +# list available scenarios +python run.py --list-scenarios -```bash -python run.py +# run with verbose round-by-round output +python run.py --scenario lateral_movement --rounds 15 --verbose + +# reproducible run โ€” share the seed and anyone gets the same outcome +python run.py --scenario cloud_iam --rounds 20 --seed 42 + +# supply chain / CI-CD compromise +python run.py --scenario supply_chain --rounds 12 --verbose --seed 7 +``` + +Example output (`--seed 13` gives a run where both attacker and defender are active): + +``` +$ python run.py --scenario lateral_movement --rounds 15 --seed 13 + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ cyber-sim-mas โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Lateral Movement โ”‚ +โ”‚ Attacker gains DMZ foothold and pivots toward internal assets โ”‚ +โ”‚ โ”‚ +โ”‚ Nodes: 5 ยท Rounds: 15 ยท Detection threshold: 3 ยท Techniques: 7 ยท โ”‚ +โ”‚ seed=13 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + + Simulation Results โ€” lateral_movement +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ Metric โ”‚ Value โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Total rounds โ”‚ 15 โ”‚ +โ”‚ MTTD (mean time to detect) โ”‚ 3 rounds โ”‚ +โ”‚ MTTR (mean time to respond) โ”‚ 4 rounds โ”‚ +โ”‚ Peak blast radius โ”‚ 100.0% โ”‚ +โ”‚ Nodes compromised (final) โ”‚ 5 โ”‚ +โ”‚ Attack success rate โ”‚ 83.3% โ”‚ +โ”‚ Defender efficiency โ”‚ 20.0% โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ ``` +Omit `--seed` for a fresh random run each time. Pass the same seed to anyone you want to discuss the same outcome with. + --- -## โœ… TODO +## Architecture + +```mermaid +graph TD + CLI[run.py CLI] --> SIM[Simulation Engine] + SIM --> ATK[AttackerAgent] + SIM --> DEF[DefenderAgent] + SIM --> MET[SimulationMetrics] + + ATK --> NET[Network] + DEF --> NET + NET --> N1[Node DMZ] + NET --> N2[Node Internal] + NET --> N3[Node Cloud/CICD] + + ATK --> TECH[techniques.py\nMITRE ATT&CK] + ATK --> WEIGHTS[Adaptive Weights\nper-technique] + + DEF --> PHASES[Response Phases\nMonitor โ†’ Investigate\nโ†’ Contain โ†’ Remediate] + MET --> MTTD[MTTD] + MET --> MTTR[MTTR] + MET --> BLAST[Blast Radius] + + SCEN[Scenarios] --> SIM + SCEN --> S1[lateral_movement] + SCEN --> S2[cloud_iam] + SCEN --> S3[supply_chain] +``` -* [ ] Add lateral movement and network graph -* [ ] Introduce Reinforcement Learning (PettingZoo/Gym) -* [ ] Model AWS or K8s-specific attack/defense behavior -* [ ] Visualization (grid or graph) +--- +## Adaptive threat model -## ๐Ÿ“œ License +The attacker adapts its technique selection across rounds using a simple reinforcement heuristic โ€” not ML, but a meaningful approximation of real adversarial behavior: -MIT License โ€” free to use and modify. +- Each technique starts with equal weight `1.0` +- Success: `weight *= 1.3` (capped at `5.0`) +- Failure: `weight *= 0.75` (floored at `0.1`) +- Technique selection is a **weighted random draw** from applicable candidates for the current stage and node +This means the attacker naturally converges toward techniques that work on the specific network topology it's facing โ€” brute-force SSH gets deprioritized after repeated failures, while silent credential theft techniques get amplified if they succeed early. -## ๐Ÿ™‹โ€โ™‚๏ธ About +The mechanism is intentionally kept simple. The goal isn't to model a sophisticated APT โ€” it's to demonstrate that even basic strategy adaptation changes detection and response dynamics in measurable ways. -Built by SentinelByte. +--- -Exploring Multi Agent Systems (MAS) for autonomous defense, cloud remediation, and adversarial simulation. +## Threat model and assumptions -```` -gitignore +**Attacker assumptions:** +- External attacker with no prior knowledge of the network +- Follows a simplified kill chain (Recon โ†’ Initial Access โ†’ Persistence โ†’ Lateral Movement โ†’ Exfiltration) +- Adapts technique prioritization based on success history +- Cannot bypass isolation โ€” if a node is isolated, it's unreachable -## Python -__pycache__/ -*.pyc -*.pyo -*.pyd -*.log +**Defender assumptions:** +- Detection is probabilistic โ€” depends on technique noise level and node detection difficulty +- Responses are delayed (configurable `response_delay`) to model IR lead time +- Can patch (keep node online, slower) or isolate (fast, breaks availability) +- Alert fatigue modeled via configurable `detection_threshold` -## Envs -venv/ -.env +**Limitations:** +- No actual network routing โ€” reachability is zone-based, not graph-based +- Techniques don't chain (no credential reuse across nodes) +- Attacker has perfect knowledge of services once recon succeeds +- No notion of time-of-day, attacker dwell time, or concurrent attacks +- The simulation is discrete-round, not continuous โ€” timing within a round isn't modeled -## IDEs -.vscode/ -.idea/ -```` +These limitations are deliberate scope constraints, not bugs. A simulation that's too complex to understand in 10 minutes doesn't serve the learning purpose. --- -## ๐Ÿท๏ธ GitHub Tags and Topics - -Repo Tags: +## Project structure ``` -cybersecurity, multi-agent-system, red-team, blue-team, simulation, python, cloud-security, SentinelByte +cyber-sim-mas/ +โ”œโ”€โ”€ cybersim/ +โ”‚ โ”œโ”€โ”€ agents/ +โ”‚ โ”‚ โ”œโ”€โ”€ attacker.py # Adaptive kill-chain attacker +โ”‚ โ”‚ โ””โ”€โ”€ defender.py # Detection + IR response phases +โ”‚ โ”œโ”€โ”€ core/ +โ”‚ โ”‚ โ”œโ”€โ”€ simulation.py # Engine (decoupled from display) +โ”‚ โ”‚ โ””โ”€โ”€ metrics.py # MTTD, MTTR, blast radius +โ”‚ โ”œโ”€โ”€ env/ +โ”‚ โ”‚ โ”œโ”€โ”€ node.py # Asset model (CVSS, zone, state) +โ”‚ โ”‚ โ””โ”€โ”€ network.py # Topology + reachability +โ”‚ โ”œโ”€โ”€ scenarios/ +โ”‚ โ”‚ โ”œโ”€โ”€ lateral_movement.py +โ”‚ โ”‚ โ”œโ”€โ”€ cloud_iam.py +โ”‚ โ”‚ โ””โ”€โ”€ supply_chain.py +โ”‚ โ””โ”€โ”€ techniques.py # MITRE ATT&CK technique definitions +โ”œโ”€โ”€ tests/ # 52 unit + integration tests +โ”œโ”€โ”€ .github/workflows/ci.yml # Lint, test, bandit scan +โ”œโ”€โ”€ run.py # CLI entrypoint +โ””โ”€โ”€ pyproject.toml +``` + +--- + +## Development + +```bash +pip install -e ".[dev]" + +# run tests +pytest + +# lint +ruff check cybersim/ tests/ run.py + +# static security analysis +bandit -r cybersim/ -ll ``` + +Tests cover node state transitions, attacker stage progression, adaptive weight behavior, defender detection phases, and full simulation invariants across all three scenarios. Results are seeded for determinism. + +--- + +## What's next + +A few things I'd like to add when time allows: + +- **Graph-based reachability** โ€” actual adjacency between nodes rather than zone-level logic +- **Credential reuse** โ€” attacker can pivot credentials stolen on one node to authenticate on another +- **RL-based defender** โ€” a defender that learns optimal response policies across many simulation runs (the current heuristic is a reasonable baseline but it doesn't adapt) +- **JSON output** โ€” structured results for feeding into dashboards or comparing runs programmatically + +--- + +## License + +MIT โ€” see [LICENSE](LICENSE). + +Built by [SentinelByte](https://github.com/SentinelByte). diff --git a/tests/test_attacker.py b/tests/test_attacker.py new file mode 100644 index 0000000..0fdac42 --- /dev/null +++ b/tests/test_attacker.py @@ -0,0 +1,233 @@ +""" +Tests for the adaptive attacker agent. + +Because the attacker's act() method is probabilistic, we use random.seed() +to make results deterministic within each test. The seed is chosen once per +test class so that the scenario (techniques chosen, outcomes) is stable across +test runs without being brittle if unrelated code changes. +""" + +import random + +import pytest + +from cybersim.agents.attacker import ( + DECAY_FACTOR, + MAX_WEIGHT, + MIN_WEIGHT, + REINFORCE_FACTOR, + AttackerAgent, +) +from cybersim.env.node import Node +from cybersim.techniques import ( + LATERAL_MOVEMENT_TECHNIQUES, + AttackStage, + Technique, +) + + +def make_node(node_id="target", services=None, zone="dmz", cvss_scores=None): + return Node( + node_id=node_id, + services=services or ["http", "ssh"], + zone=zone, + cvss_scores=cvss_scores or {"http": 9.8, "ssh": 7.0}, + ) + + +class TestAdaptiveWeights: + """Weight updates are the core adaptive mechanism โ€” test them directly.""" + + def test_success_increases_weight(self): + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + technique_id = LATERAL_MOVEMENT_TECHNIQUES[0].id + initial = attacker.weights[technique_id] + + attacker._update_weights(technique_id, success=True) + + assert attacker.weights[technique_id] > initial + assert attacker.weights[technique_id] == pytest.approx(initial * REINFORCE_FACTOR) + + def test_failure_decreases_weight(self): + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + technique_id = LATERAL_MOVEMENT_TECHNIQUES[0].id + initial = attacker.weights[technique_id] + + attacker._update_weights(technique_id, success=False) + + assert attacker.weights[technique_id] < initial + assert attacker.weights[technique_id] == pytest.approx(initial * DECAY_FACTOR) + + def test_weight_capped_at_max(self): + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + tid = LATERAL_MOVEMENT_TECHNIQUES[0].id + attacker.weights[tid] = MAX_WEIGHT + + attacker._update_weights(tid, success=True) + + assert attacker.weights[tid] == MAX_WEIGHT + + def test_weight_floored_at_min(self): + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + tid = LATERAL_MOVEMENT_TECHNIQUES[0].id + attacker.weights[tid] = MIN_WEIGHT + + attacker._update_weights(tid, success=False) + + assert attacker.weights[tid] == MIN_WEIGHT + + def test_repeated_successes_shift_distribution(self): + """ + After many successes on one technique, it should have a noticeably + higher weight than untouched techniques. + """ + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + favoured_id = LATERAL_MOVEMENT_TECHNIQUES[0].id + + for _ in range(10): + attacker._update_weights(favoured_id, success=True) + + other_ids = [t.id for t in LATERAL_MOVEMENT_TECHNIQUES[1:]] + assert all(attacker.weights[favoured_id] > attacker.weights[oid] for oid in other_ids) + + +class TestStageProgression: + def test_first_action_is_recon(self): + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + node = make_node() + stage = attacker._current_stage(node) + assert stage == AttackStage.RECON + + def test_after_recon_moves_to_access(self): + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + node = make_node() + + # Simulate a recon entry in history + from cybersim.agents.attacker import AttackResult + attacker.history.append( + AttackResult( + technique=LATERAL_MOVEMENT_TECHNIQUES[0], + target_node_id=node.id, + success=True, + stage=AttackStage.RECON, + ) + ) + + stage = attacker._current_stage(node) + assert stage == AttackStage.INITIAL_ACCESS + + def test_after_compromise_moves_to_persistence(self): + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + node = make_node() + node.compromised = True + + from cybersim.agents.attacker import AttackResult + attacker.history.append( + AttackResult( + technique=LATERAL_MOVEMENT_TECHNIQUES[0], + target_node_id=node.id, + success=True, + stage=AttackStage.RECON, + ) + ) + + stage = attacker._current_stage(node) + assert stage == AttackStage.PERSISTENCE + + def test_persistence_then_lateral_movement(self): + """ + After establishing persistence on one node, the attacker should select + LATERAL_MOVEMENT for a new node โ€” but only after reconning it first. + Recon is always the first step on any fresh target. + """ + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + owned_node = make_node() + owned_node.compromised = True + owned_node.has_persistence = True + attacker.compromised_nodes.add(owned_node.id) + + new_node = make_node(node_id="pivot-target", services=["ssh"]) + + from cybersim.agents.attacker import AttackResult + + # Fresh pivot target โ€” attacker reconns it first + assert attacker._current_stage(new_node) == AttackStage.RECON + + # After recon is recorded, lateral movement becomes the selected stage + attacker.history.append( + AttackResult( + technique=LATERAL_MOVEMENT_TECHNIQUES[0], + target_node_id=new_node.id, + success=True, + stage=AttackStage.RECON, + ) + ) + assert attacker._current_stage(new_node) == AttackStage.LATERAL_MOVEMENT + + +class TestAttackAttempt: + def test_patched_node_always_fails(self): + random.seed(1) + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + node = make_node(cvss_scores={"http": 10.0}) + node.is_patched = True + + technique = next( + t for t in LATERAL_MOVEMENT_TECHNIQUES if t.stage == AttackStage.INITIAL_ACCESS + ) + success = attacker._attempt(node, technique) + + assert not success + + def test_isolated_node_always_fails(self): + random.seed(1) + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + node = make_node() + node.isolated = True + + technique = next( + t for t in LATERAL_MOVEMENT_TECHNIQUES if t.stage == AttackStage.INITIAL_ACCESS + ) + success = attacker._attempt(node, technique) + + assert not success + + def test_successful_initial_access_marks_node_compromised(self): + # Force a success by using high-CVSS node and a seeded run + random.seed(42) + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + node = make_node(cvss_scores={"http": 10.0, "https": 10.0}) + + technique = Technique( + id="T1190", + name="Exploit Public-Facing Application", + stage=AttackStage.INITIAL_ACCESS, + applicable_services=("http",), + base_success_rate=0.99, # near-certain success + noise_level=0.5, + ) + + success = attacker._attempt(node, technique) + + if success: + assert node.compromised + assert node.id in attacker.compromised_nodes + + def test_attempt_always_logs_an_entry(self): + random.seed(5) + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + node = make_node() + + technique = LATERAL_MOVEMENT_TECHNIQUES[0] + attacker._attempt(node, technique) + + assert len(node.logs) == 1 + + def test_history_grows_each_round(self): + random.seed(99) + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + nodes = [make_node(node_id=f"n{i}", zone="dmz") for i in range(3)] + + results = attacker.act(nodes) + assert len(attacker.history) == len(results) + assert len(results) <= len(nodes) diff --git a/tests/test_defender.py b/tests/test_defender.py new file mode 100644 index 0000000..e08d794 --- /dev/null +++ b/tests/test_defender.py @@ -0,0 +1,159 @@ +"""Tests for the defender detection and response phases.""" + +from cybersim.agents.defender import DefenderAgent, ResponsePhase +from cybersim.env.node import Node + + +def make_node(node_id="target", services=None, alert_count=0): + n = Node(node_id=node_id, services=services or ["http"], zone="internal") + n.alert_count = alert_count + return n + + +def add_visible_log(node: Node, count: int = 1): + for i in range(count): + node.log_event(f"T10{i}", f"Technique {i}", success=True, visible=True) + + +class TestDetectionThreshold: + def test_below_threshold_no_action(self): + defender = DefenderAgent(detection_threshold=3) + node = make_node() + add_visible_log(node, count=2) + + actions = defender.act([node], round_num=1) + assert actions == [] + + def test_at_threshold_triggers_investigation(self): + defender = DefenderAgent(detection_threshold=3) + node = make_node() + add_visible_log(node, count=3) + + actions = defender.act([node], round_num=1) + + assert len(actions) == 1 + assert actions[0].phase == ResponsePhase.INVESTIGATE + assert actions[0].node_id == node.id + + def test_investigation_only_fires_once(self): + defender = DefenderAgent(detection_threshold=1) + node = make_node() + add_visible_log(node, count=1) + + r1 = defender.act([node], round_num=1) + add_visible_log(node, count=5) + r2 = defender.act([node], round_num=2) + + # Round 1 triggers INVESTIGATE; round 2 should trigger CONTAIN/REMEDIATE, + # not another INVESTIGATE + assert r1[0].phase == ResponsePhase.INVESTIGATE + investigate_in_r2 = [a for a in r2 if a.phase == ResponsePhase.INVESTIGATE] + assert investigate_in_r2 == [] + + def test_alert_count_accumulates_across_rounds(self): + defender = DefenderAgent(detection_threshold=3) + node = make_node() + + add_visible_log(node, count=1) + defender.act([node], round_num=1) + assert node.alert_count == 1 + + add_visible_log(node, count=2) + actions = defender.act([node], round_num=2) + assert node.alert_count == 3 + assert actions[0].phase == ResponsePhase.INVESTIGATE + + def test_processed_logs_not_recounted(self): + defender = DefenderAgent(detection_threshold=3) + node = make_node() + add_visible_log(node, count=2) + + defender.act([node], round_num=1) + # Don't add more logs โ€” processed ones should not count again + actions = defender.act([node], round_num=2) + assert actions == [] + + +class TestResponseDelay: + def test_containment_fires_after_delay(self): + defender = DefenderAgent(detection_threshold=1, response_delay=2) + node = make_node() + add_visible_log(node, count=1) + + r1 = defender.act([node], round_num=1) # INVESTIGATE + r2 = defender.act([node], round_num=2) # still within delay + r3 = defender.act([node], round_num=3) # delay elapsed โ†’ CONTAIN/REMEDIATE + + assert r1[0].phase == ResponsePhase.INVESTIGATE + assert r2 == [] + assert r3[0].phase in (ResponsePhase.CONTAIN, ResponsePhase.REMEDIATE) + + def test_immediate_response_with_zero_delay(self): + defender = DefenderAgent(detection_threshold=1, response_delay=0) + node = make_node() + add_visible_log(node, count=1) + + r1 = defender.act([node], round_num=1) + # Both INVESTIGATE and REMEDIATE should happen in round 1 + phases = {a.phase for a in r1} + assert ResponsePhase.INVESTIGATE in phases + + +class TestContainmentModes: + def test_patch_only_mode(self): + defender = DefenderAgent(detection_threshold=1, response_delay=1, isolate_on_detect=False) + node = make_node() + add_visible_log(node, count=1) + + defender.act([node], round_num=1) # INVESTIGATE + defender.act([node], round_num=2) # REMEDIATE + + assert node.is_patched + assert not node.isolated + + def test_isolate_mode(self): + defender = DefenderAgent(detection_threshold=1, response_delay=1, isolate_on_detect=True) + node = make_node() + add_visible_log(node, count=1) + + defender.act([node], round_num=1) # INVESTIGATE + defender.act([node], round_num=2) # CONTAIN (isolate + patch) + + assert node.isolated + assert node.is_patched + + def test_no_double_patch(self): + """Patching an already-patched node should not produce a second action.""" + defender = DefenderAgent(detection_threshold=1, response_delay=1) + node = make_node() + node.is_patched = True + add_visible_log(node, count=1) + + defender.act([node], round_num=1) # INVESTIGATE + actions = defender.act([node], round_num=2) + + patch_actions = [a for a in actions if "patched" in a.detail] + assert patch_actions == [] + + +class TestFirstDetectionRound: + def test_first_detection_round_recorded(self): + defender = DefenderAgent(detection_threshold=1) + node = make_node() + add_visible_log(node, count=1) + + assert defender.first_detection_round is None + defender.act([node], round_num=5) + assert defender.first_detection_round == 5 + + def test_first_detection_is_minimum_across_nodes(self): + defender = DefenderAgent(detection_threshold=1) + n1 = make_node(node_id="n1") + n2 = make_node(node_id="n2") + add_visible_log(n1, count=1) + add_visible_log(n2, count=1) + + defender.act([n1], round_num=3) + defender.act([n2], round_num=7) + + assert defender.first_detection_round == 3 diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..b64991b --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,90 @@ +"""Tests for Node state transitions and log behaviour.""" + +from cybersim.env.node import Node + + +def make_node(**kwargs) -> Node: + defaults = dict(node_id="test-node", services=["http", "ssh"], zone="internal") + defaults.update(kwargs) + return Node(**defaults) + + +class TestNodeDefaults: + def test_starts_safe(self): + node = make_node() + assert not node.compromised + assert not node.has_persistence + assert not node.is_patched + assert not node.isolated + assert node.alert_count == 0 + + def test_cvss_default_fallback(self): + node = make_node() + assert node.cvss_for_service("http") == 5.0 + assert node.cvss_for_service("nonexistent") == 5.0 + + def test_cvss_custom_score(self): + node = make_node(cvss_scores={"http": 9.8}) + assert node.cvss_for_service("http") == 9.8 + + def test_detection_difficulty_clamped(self): + node = make_node(detection_difficulty=2.5) + assert node.detection_difficulty == 1.0 + node2 = make_node(detection_difficulty=-1.0) + assert node2.detection_difficulty == 0.0 + + +class TestNodeLogs: + def test_log_event_appended(self): + node = make_node() + node.log_event("T1046", "Network Scan", success=True, visible=True) + assert len(node.logs) == 1 + assert node.logs[0].technique_id == "T1046" + assert node.logs[0].visible + + def test_unprocessed_visible_logs_filters_correctly(self): + node = make_node() + node.log_event("T1046", "Scan", success=True, visible=True) + node.log_event("T1190", "Exploit", success=True, visible=False) # invisible + node.log_event("T1021", "SSH", success=True, visible=True) + node.logs[0].processed = True # mark first as already processed + + unprocessed = node.unprocessed_visible_logs() + assert len(unprocessed) == 1 + assert unprocessed[0].technique_id == "T1021" + + def test_reset_clears_all_state(self): + node = make_node(cvss_scores={"http": 9.8}) + node.compromised = True + node.has_persistence = True + node.is_patched = True + node.isolated = True + node.alert_count = 7 + node.log_event("T1046", "Scan", success=True, visible=True) + + node.reset() + + assert not node.compromised + assert not node.has_persistence + assert not node.is_patched + assert not node.isolated + assert node.alert_count == 0 + assert len(node.logs) == 0 + # CVSS scores are config, not state โ€” should survive reset + assert node.cvss_for_service("http") == 9.8 + + +class TestNodeMaxCvss: + def test_max_cvss_with_services(self): + node = make_node(cvss_scores={"http": 9.8, "ssh": 6.5}) + assert node.max_cvss(("http", "ssh")) == 9.8 + assert node.max_cvss(("ssh",)) == 6.5 + + def test_max_cvss_empty_services_uses_all(self): + node = make_node(cvss_scores={"http": 9.8, "ssh": 6.5}) + assert node.max_cvss() == 9.8 + + def test_max_cvss_no_matching_services_returns_zero(self): + node = make_node(cvss_scores={"http": 9.8}) + # technique targets "smb" but node doesn't run it + assert node.max_cvss(("smb",)) == 0.0 diff --git a/tests/test_simulation.py b/tests/test_simulation.py new file mode 100644 index 0000000..15f75ff --- /dev/null +++ b/tests/test_simulation.py @@ -0,0 +1,153 @@ +""" +Integration-level tests for the simulation engine and scenarios. + +These tests run full simulations with fixed random seeds so results are +deterministic. They verify structural invariants (metrics collected, state +transitions make sense) rather than exact outcomes, which would be brittle +given the probabilistic nature of the sim. +""" + +import random + +from cybersim.agents.attacker import AttackerAgent +from cybersim.core.simulation import SimulationConfig, run_simulation +from cybersim.scenarios.cloud_iam import build as build_cloud +from cybersim.scenarios.lateral_movement import build as build_lateral +from cybersim.scenarios.supply_chain import build as build_supply +from cybersim.techniques import LATERAL_MOVEMENT_TECHNIQUES + + +def run_scenario_seeded(build_fn, rounds=15, seed=42): + random.seed(seed) + network, attacker, defender = build_fn() + config = SimulationConfig(scenario_name="test", rounds=rounds) + return run_simulation(network, attacker, defender, config) + + +class TestSimulationInvariants: + def test_metrics_has_correct_round_count(self): + random.seed(1) + network, attacker, defender = build_lateral() + config = SimulationConfig(scenario_name="test", rounds=10) + metrics = run_simulation(network, attacker, defender, config) + assert len(metrics.snapshots) == 10 + + def test_peak_blast_radius_between_zero_and_one(self): + metrics = run_scenario_seeded(build_lateral) + assert 0.0 <= metrics.peak_blast_radius <= 1.0 + + def test_attack_success_rate_between_zero_and_one(self): + metrics = run_scenario_seeded(build_lateral) + assert 0.0 <= metrics.attack_success_rate <= 1.0 + + def test_defender_efficiency_between_zero_and_one(self): + metrics = run_scenario_seeded(build_lateral) + assert 0.0 <= metrics.defender_efficiency <= 1.0 + + def test_compromised_count_never_decreases(self): + """Compromise is permanent โ€” nodes can't self-heal.""" + random.seed(7) + network, attacker, defender = build_lateral() + config = SimulationConfig(scenario_name="test", rounds=15) + metrics = run_simulation(network, attacker, defender, config) + + counts = [len(s.compromised) for s in metrics.snapshots] + for i in range(1, len(counts)): + assert counts[i] >= counts[i - 1] + + def test_mttd_is_none_or_non_negative(self): + metrics = run_scenario_seeded(build_lateral, seed=99) + assert metrics.mttd is None or metrics.mttd >= 0 + + def test_mttd_never_negative(self): + # Run multiple seeds; MTTD should never be negative + for seed in range(10): + metrics = run_scenario_seeded(build_lateral, seed=seed) + if metrics.mttd is not None: + assert metrics.mttd >= 0 + + +class TestScenarioBuilds: + """Smoke tests โ€” confirm each scenario builds and runs without error.""" + + def test_lateral_movement_builds(self): + network, attacker, defender = build_lateral() + assert len(network.nodes) > 0 + assert len(attacker.techniques) > 0 + + def test_cloud_iam_builds(self): + network, attacker, defender = build_cloud() + assert len(network.nodes) > 0 + # Cloud scenario should have cloud-zone nodes + zones = {n.zone for n in network.nodes} + assert "cloud" in zones + + def test_supply_chain_builds(self): + network, attacker, defender = build_supply() + assert len(network.nodes) > 0 + zones = {n.zone for n in network.nodes} + assert "cicd" in zones + + def test_all_scenarios_run_without_exception(self): + for build_fn in (build_lateral, build_cloud, build_supply): + random.seed(0) + network, attacker, defender = build_fn() + config = SimulationConfig(scenario_name="smoke", rounds=5) + metrics = run_simulation(network, attacker, defender, config) + assert metrics.to_dict()["total_rounds"] == 5 + + +class TestNetworkReachability: + def test_isolated_node_not_in_reachable(self): + random.seed(0) + network, attacker, defender = build_lateral() + # Manually isolate all nodes + for node in network.nodes: + node.isolated = True + reachable = network.reachable_from(set()) + assert reachable == [] + + def test_dmz_nodes_reachable_without_foothold(self): + network, attacker, defender = build_lateral() + reachable = network.reachable_from(set()) + zones = {n.zone for n in reachable} + assert "dmz" in zones + assert "internal" not in zones + + def test_internal_nodes_reachable_after_foothold(self): + network, attacker, defender = build_lateral() + dmz_id = network.entry_nodes()[0].id + reachable = network.reachable_from({dmz_id}) + zones = {n.zone for n in reachable} + assert "internal" in zones + + +class TestAdaptiveBehaviour: + """ + Verify that the adaptive weight mechanism demonstrably shifts technique + selection over time. We can't guarantee exact outcomes (probabilistic) + but we CAN assert that weight distribution changes after repeated success. + """ + + def test_weights_change_after_simulation(self): + random.seed(3) + network, attacker, defender = build_lateral() + initial_weights = dict(attacker.weights) + + config = SimulationConfig(scenario_name="test", rounds=10) + run_simulation(network, attacker, defender, config) + + # At least some weights must have shifted โ€” the attacker learned something + assert attacker.weights != initial_weights + + def test_successful_technique_gains_weight(self): + attacker = AttackerAgent(techniques=LATERAL_MOVEMENT_TECHNIQUES) + tid = LATERAL_MOVEMENT_TECHNIQUES[0].id + + # Apply 5 successive successes + for _ in range(5): + attacker._update_weights(tid, success=True) + + # This technique should now outweigh all others + others = [t.id for t in LATERAL_MOVEMENT_TECHNIQUES[1:]] + assert all(attacker.weights[tid] > attacker.weights[o] for o in others)