Spaces:
Sleeping
Sleeping
| """ | |
| Test Suite for OpenEnv-CloudSOC Benchmark | |
| ========================================== | |
| Run: python test_cloudsoc.py [--task easy|medium|hard|all] [--verbose] [--quick] | |
| """ | |
| import json | |
| import sys | |
| import unittest | |
| from typing import Dict, List, Optional | |
| from cloud_soc_env import CloudSOCEnv, CloudState, SCENARIOS, InstanceState, IncidentPhase | |
| class TestEnvironmentInit(unittest.TestCase): | |
| """Test environment initialization""" | |
| def test_easy_task_init(self): | |
| """Test easy task initialization""" | |
| env = CloudSOCEnv(task="easy", seed=42) | |
| self.assertEqual(env.task, "easy") | |
| self.assertEqual(env.max_steps, 15) | |
| self.assertTrue(len(env.scenario["required_flags"]) > 0) | |
| self.assertIsNotNone(env.state) | |
| def test_medium_task_init(self): | |
| """Test medium task initialization""" | |
| env = CloudSOCEnv(task="medium", seed=42) | |
| self.assertEqual(env.task, "medium") | |
| self.assertEqual(env.max_steps, 25) | |
| self.assertTrue(len(env.scenario["required_flags"]) > len(SCENARIOS["easy"]["required_flags"])) | |
| def test_hard_task_init(self): | |
| """Test hard task initialization""" | |
| env = CloudSOCEnv(task="hard", seed=42) | |
| self.assertEqual(env.task, "hard") | |
| self.assertEqual(env.max_steps, 40) | |
| self.assertTrue(len(env.scenario["required_flags"]) > len(SCENARIOS["medium"]["required_flags"])) | |
| def test_deterministic_seeding(self): | |
| """Test that same seed produces same initial state""" | |
| env1 = CloudSOCEnv(task="easy", seed=42) | |
| env2 = CloudSOCEnv(task="easy", seed=42) | |
| obs1, _ = env1.reset() | |
| obs2, _ = env2.reset() | |
| # Same seed should produce same number of logs | |
| self.assertEqual(len(env1.state.logs), len(env2.state.logs)) | |
| self.assertEqual(len(env1.state.instances), len(env2.state.instances)) | |
| def test_different_seeds_different_states(self): | |
| """Test that different seeds produce different states""" | |
| env1 = CloudSOCEnv(task="easy", seed=42) | |
| env2 = CloudSOCEnv(task="easy", seed=43) | |
| env1.reset() | |
| env2.reset() | |
| # Different seeds should produce different instance IDs | |
| ids1 = set(env1.state.instances.keys()) | |
| ids2 = set(env2.state.instances.keys()) | |
| # At least some instances should be different | |
| self.assertNotEqual(ids1, ids2) | |
| class TestToolExecution(unittest.TestCase): | |
| """Test tool execution and validation""" | |
| def setUp(self): | |
| self.env = CloudSOCEnv(task="easy", seed=42) | |
| self.env.reset() | |
| def test_valid_tool_call(self): | |
| """Test valid tool execution""" | |
| action = json.dumps({ | |
| "thought": "Check current alerts", | |
| "tool": "aws.soc.get_alerts", | |
| "args": {} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| self.assertIsNotNone(obs) | |
| self.assertIsInstance(reward, float) | |
| self.assertFalse(term) | |
| self.assertEqual(info["last_action_error"], None) | |
| def test_invalid_json(self): | |
| """Test handling of invalid JSON""" | |
| action = "not valid json" | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| self.assertIn("PARSE_ERROR", info["last_action_error"]) | |
| self.assertEqual(reward, -0.02) | |
| def test_invalid_tool_name(self): | |
| """Test handling of invalid tool name""" | |
| action = json.dumps({ | |
| "thought": "Try invalid tool", | |
| "tool": "aws.invalid.tool", | |
| "args": {} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| self.assertIn("VALIDATION_ERROR", info["last_action_error"]) | |
| self.assertEqual(reward, -0.02) | |
| def test_missing_required_param(self): | |
| """Test handling of missing required parameters""" | |
| action = json.dumps({ | |
| "thought": "Get bucket policy without bucket", | |
| "tool": "aws.s3.get_bucket_policy", | |
| "args": {} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| self.assertIn("MISSING_PARAM", info["last_action_error"]) | |
| self.assertEqual(reward, -0.01) | |
| def test_cloudwatch_basic_query(self): | |
| """Test basic CloudWatch query""" | |
| action = json.dumps({ | |
| "thought": "Query logs", | |
| "tool": "aws.cloudwatch.query_basic", | |
| "args": {"log_group": "/aws/ec2"} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| # Basic query should cost -0.01 | |
| self.assertAlmostEqual(reward, -0.01, places=2) | |
| self.assertEqual(self.env.query_costs, 0.01) | |
| def test_cloudwatch_deep_query(self): | |
| """Test deep CloudWatch query""" | |
| action = json.dumps({ | |
| "thought": "Deep query", | |
| "tool": "aws.cloudwatch.query_deep", | |
| "args": {"log_group": "/aws/ec2"} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| # Deep query should cost -0.05 | |
| self.assertAlmostEqual(reward, -0.05, places=2) | |
| self.assertEqual(self.env.query_costs, 0.05) | |
| class TestPreconditions(unittest.TestCase): | |
| """Test action preconditions (Mechanic #3)""" | |
| def setUp(self): | |
| self.env = CloudSOCEnv(task="easy", seed=42) | |
| self.env.reset() | |
| # Get a compromised instance | |
| self.instance_id = None | |
| for iid, inst in self.env.state.instances.items(): | |
| if inst.is_compromised: | |
| self.instance_id = iid | |
| break | |
| self.assertTrue(self.instance_id is not None, "No compromised instance found") | |
| def test_isolate_without_snapshot_fails(self): | |
| """Test that isolation without snapshot fails""" | |
| if not self.instance_id: | |
| self.skipTest("No compromised instance") | |
| action = json.dumps({ | |
| "thought": "Isolate without snapshot", | |
| "tool": "aws.ec2.isolate", | |
| "args": {"instance_id": self.instance_id} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| self.assertIn("PRECONDITION_FAILED", info["last_action_error"]) | |
| self.assertEqual(reward, -0.05) | |
| def test_snapshot_then_isolate_succeeds(self): | |
| """Test that snapshot followed by isolate succeeds""" | |
| if not self.instance_id: | |
| self.skipTest("No compromised instance") | |
| # Step 1: Snapshot | |
| action1 = json.dumps({ | |
| "thought": "Take forensic snapshot", | |
| "tool": "aws.ec2.snapshot", | |
| "args": {"instance_id": self.instance_id} | |
| }) | |
| self.env.step(action1) | |
| # Verify snapshot taken | |
| inst = self.env.state.instances[self.instance_id] | |
| self.assertTrue(inst.has_forensic_snapshot) | |
| # Step 2: Isolate (should succeed now) | |
| action2 = json.dumps({ | |
| "thought": "Isolate instance", | |
| "tool": "aws.ec2.isolate", | |
| "args": {"instance_id": self.instance_id} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action2) | |
| # Should succeed | |
| self.assertIsNone(info["last_action_error"]) | |
| self.assertGreater(reward, 0) | |
| self.assertEqual(inst.state, InstanceState.ISOLATED) | |
| class TestAdversarialTraps(unittest.TestCase): | |
| """Test adversarial traps (Mechanic #4)""" | |
| def setUp(self): | |
| self.env = CloudSOCEnv(task="easy", seed=42) | |
| self.env.reset() | |
| # Get a compromised instance | |
| self.instance_id = None | |
| for iid, inst in self.env.state.instances.items(): | |
| if inst.is_compromised: | |
| self.instance_id = iid | |
| break | |
| def test_terminate_compromised_instance_destroys_evidence(self): | |
| """Test that terminating compromised instance has severe penalty""" | |
| if not self.instance_id: | |
| self.skipTest("No compromised instance") | |
| action = json.dumps({ | |
| "thought": "Terminate compromised instance", | |
| "tool": "aws.ec2.terminate", | |
| "args": {"instance_id": self.instance_id} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| # Terminating compromised instance = -1.0 and done | |
| self.assertEqual(reward, -1.0) | |
| self.assertTrue(term) | |
| self.assertIn("FORENSIC_DATA_LOSS", info["last_action_error"]) | |
| class TestRewardShaping(unittest.TestCase): | |
| """Test reward shaping (Mechanic #5)""" | |
| def setUp(self): | |
| self.env = CloudSOCEnv(task="easy", seed=42) | |
| self.env.reset() | |
| def test_flag_discovery_reward(self): | |
| """Test that discovering flags grants rewards""" | |
| initial_flags = len(self.env.state.discovered_flags) | |
| # Query deep logs to discover flags | |
| action = json.dumps({ | |
| "thought": "Deep query logs", | |
| "tool": "aws.cloudwatch.query_deep", | |
| "args": {"log_group": "/aws/ec2"} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| new_flags = len(self.env.state.discovered_flags) | |
| # Should have discovered at least one flag | |
| self.assertGreater(new_flags, initial_flags) | |
| # Reward should be: -0.05 (query cost) + flag_discovery bonus | |
| self.assertGreater(reward, -0.05) | |
| def test_critical_action_reward(self): | |
| """Test that critical actions grant high rewards""" | |
| # Take snapshot for example | |
| instance_id = list(self.env.state.instances.keys())[0] | |
| action = json.dumps({ | |
| "thought": "Take snapshot", | |
| "tool": "aws.ec2.snapshot", | |
| "args": {"instance_id": instance_id} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| # Critical action should have positive reward | |
| self.assertGreater(reward, 0.0) | |
| class TestClosingIncident(unittest.TestCase): | |
| """Test incident closure and timeline grading""" | |
| def setUp(self): | |
| self.env = CloudSOCEnv(task="easy", seed=42) | |
| self.env.reset() | |
| def test_close_incident_with_empty_timeline(self): | |
| """Test that empty timeline is rejected""" | |
| action = json.dumps({ | |
| "thought": "Close incident", | |
| "tool": "aws.soc.close_incident", | |
| "args": {"timeline": []} | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| self.assertIn("INVALID_PARAM", info["last_action_error"]) | |
| def test_close_incident_with_timeline(self): | |
| """Test incident closure with valid timeline""" | |
| action = json.dumps({ | |
| "thought": "Close incident", | |
| "tool": "aws.soc.close_incident", | |
| "args": { | |
| "timeline": [ | |
| "Public S3 bucket detected", | |
| "Credentials discovered in bucket", | |
| "Public access blocked" | |
| ] | |
| } | |
| }) | |
| obs, reward, term, trunc, info = self.env.step(action) | |
| # Should terminate | |
| self.assertTrue(info.get("last_action_error") is None or "timeline" in info.get("last_action_error", "").lower()) | |
| def test_timeline_grading(self): | |
| """Test timeline accuracy grading""" | |
| ground_truth = self.env.scenario["ground_truth_timeline"] | |
| # Test matching timeline | |
| agent_timeline = ground_truth.copy() | |
| score = self.env._grade_timeline(agent_timeline) | |
| # Perfect match should score high | |
| self.assertGreaterEqual(score, 0.7) | |
| # Test partial match | |
| partial_timeline = ground_truth[:1] | |
| score = self.env._grade_timeline(partial_timeline) | |
| # Partial should be lower | |
| self.assertGreater(score, 0.0) | |
| self.assertLess(score, 1.0) | |
| class TestMultiTaskCampaign(unittest.TestCase): | |
| """Test multi-task campaign (Mechanic #11)""" | |
| def test_easy_task_state_export(self): | |
| """Test that easy task state can be exported""" | |
| env = CloudSOCEnv(task="easy", seed=42) | |
| env.reset() | |
| state = env.get_state_for_next_task() | |
| self.assertIsNotNone(state) | |
| self.assertIsInstance(state, CloudState) | |
| self.assertTrue(len(state.instances) > 0) | |
| def test_state_inheritance_medium(self): | |
| """Test that medium task can inherit easy task state""" | |
| env_easy = CloudSOCEnv(task="easy", seed=42) | |
| env_easy.reset() | |
| easy_state = env_easy.get_state_for_next_task() | |
| env_medium = CloudSOCEnv(task="medium", seed=42, initial_state=easy_state) | |
| env_medium.reset() | |
| # Medium should have inherited instances | |
| self.assertEqual(len(env_medium.state.instances), len(easy_state.instances)) | |
| class TestMemoryPressure(unittest.TestCase): | |
| """Test memory pressure simulation (Mechanic #6)""" | |
| def test_context_window_size(self): | |
| """Test that context window respects size limit""" | |
| from inference import ContextWindow, MAX_CONTEXT_TURNS | |
| context = ContextWindow(system_prompt="Test", max_turns=3) | |
| # Add more turns than max | |
| for i in range(5): | |
| context.add_turn( | |
| observation=f"Obs {i}", | |
| action=f"Action {i}", | |
| result=f"Result {i}" | |
| ) | |
| # Should only keep max_turns | |
| self.assertEqual(len(context.turns), 3) | |
| class TestStateSerializable(unittest.TestCase): | |
| """Test state serialization for debugging""" | |
| def test_state_to_dict(self): | |
| """Test that state can be serialized to dict""" | |
| env = CloudSOCEnv(task="easy", seed=42) | |
| env.reset() | |
| state_dict = env.state.to_dict() | |
| self.assertIn("instances", state_dict) | |
| self.assertIn("roles", state_dict) | |
| self.assertIn("buckets", state_dict) | |
| self.assertIn("discovered_flags", state_dict) | |
| self.assertIn("phase", state_dict) | |
| def run_quick_tests(): | |
| """Run quick smoke tests""" | |
| print("\n=== Quick Smoke Tests ===\n") | |
| # Test 1: Init | |
| print("1. Testing environment initialization...") | |
| for task in ["easy", "medium", "hard"]: | |
| env = CloudSOCEnv(task=task, seed=42) | |
| obs, info = env.reset() | |
| print(f" β {task}: {env.max_steps} steps, {len(env.scenario['required_flags'])} flags") | |
| # Test 2: Tool execution | |
| print("\n2. Testing tool execution...") | |
| env = CloudSOCEnv(task="easy", seed=42) | |
| env.reset() | |
| action = json.dumps({ | |
| "thought": "Get alerts", | |
| "tool": "aws.soc.get_alerts", | |
| "args": {} | |
| }) | |
| obs, reward, term, trunc, info = env.step(action) | |
| print(f" β Tool executed: reward={reward:.2f}") | |
| # Test 3: Deterministic seeding | |
| print("\n3. Testing deterministic seeding...") | |
| env1 = CloudSOCEnv(task="easy", seed=42) | |
| env2 = CloudSOCEnv(task="easy", seed=42) | |
| env1.reset() | |
| env2.reset() | |
| same = len(env1.state.logs) == len(env2.state.logs) | |
| print(f" {'β' if same else 'β'} Same seed produces same state") | |
| # Test 4: Preconditions | |
| print("\n4. Testing action preconditions...") | |
| env = CloudSOCEnv(task="easy", seed=42) | |
| env.reset() | |
| instance_id = list(env.state.instances.keys())[0] | |
| # Try isolate without snapshot (should fail) | |
| action = json.dumps({ | |
| "thought": "Isolate", | |
| "tool": "aws.ec2.isolate", | |
| "args": {"instance_id": instance_id} | |
| }) | |
| obs, reward, term, trunc, info = env.step(action) | |
| has_error = info["last_action_error"] is not None | |
| print(f" {'β' if has_error else 'β'} Precondition check works") | |
| # Test 5: Adversarial trap | |
| print("\n5. Testing adversarial trap...") | |
| env = CloudSOCEnv(task="easy", seed=42) | |
| env.reset() | |
| compromised = None | |
| for iid, inst in env.state.instances.items(): | |
| if inst.is_compromised: | |
| compromised = iid | |
| break | |
| if compromised: | |
| action = json.dumps({ | |
| "thought": "Terminate", | |
| "tool": "aws.ec2.terminate", | |
| "args": {"instance_id": compromised} | |
| }) | |
| obs, reward, term, trunc, info = env.step(action) | |
| is_trap = reward == -1.0 and term | |
| print(f" {'β' if is_trap else 'β'} Adversarial trap triggered (-1.0 penalty)") | |
| print("\nβ All quick tests passed!\n") | |
| if __name__ == "__main__": | |
| import argparse | |
| parser = argparse.ArgumentParser(description="Test CloudSOC benchmark") | |
| parser.add_argument("--quick", action="store_true", help="Run quick smoke tests only") | |
| parser.add_argument("--verbose", action="store_true", help="Verbose output") | |
| args = parser.parse_args() | |
| if args.quick: | |
| run_quick_tests() | |
| else: | |
| # Run unittest suite | |
| unittest.main(verbosity=2 if args.verbose else 1) | |