diff --git a/app/blue_2.py b/app/blue_2.py index 455fe96..2154907 100644 --- a/app/blue_2.py +++ b/app/blue_2.py @@ -1,22 +1,3 @@ -from app.utility.base_world import BaseWorld -from plugins.training.app.c_flag import Flag - - -class AutonomousBlue2(Flag): - name = 'Malicious file on system' - challenge = 'Write a file on the Windows machine under the C:\\Users\\Public directory. Get the SHA256 hash of ' \ - 'this file, and write it to C:\\Users\\Public\\malicious_files.txt. The autonomous defender should ' \ - 'automatically find and delete the file.' - extra_info = """""" - visible = False - - async def verify(self, services): - def is_file_deleted(operation): - return operation.ran_ability_id('5ec7ae3b-c909-41bb-9b6b-dadec409cd40') - - for op in await services.get('data_svc').locate('operations', - match=dict(access=BaseWorld.Access.BLUE, - name='Blue Autonomous')): - if await self._is_file_found(op) and is_file_deleted(op): - return True - return False +# This module is kept for backwards compatibility. +# The canonical implementation is at app/flags/autonomous/blue_2.py. +from plugins.training.app.flags.autonomous.blue_2 import AutonomousBlue2 # noqa: F401 diff --git a/app/flags/autonomous/blue_2.py b/app/flags/autonomous/blue_2.py new file mode 100644 index 0000000..c764e5c --- /dev/null +++ b/app/flags/autonomous/blue_2.py @@ -0,0 +1,21 @@ +from app.utility.base_world import BaseWorld +from plugins.training.app.c_flag import Flag + + +class AutonomousBlue2(Flag): + name = 'Malicious file on system' + challenge = 'Write a file on the Windows machine under the C:\\Users\\Public directory. Get the SHA256 hash of ' \ + 'this file, and write it to C:\\Users\\Public\\malicious_files.txt. The autonomous defender should ' \ + 'automatically find and delete the file.' + extra_info = """""" + + async def verify(self, services): + def is_file_deleted(operation): + return operation.ran_ability_id('5ec7ae3b-c909-41bb-9b6b-dadec409cd40') + + for op in await services.get('data_svc').locate('operations', + match=dict(access=BaseWorld.Access.BLUE, + name='Blue Autonomous')): + if await self._is_file_found(op) and is_file_deleted(op): + return True + return False diff --git a/data/certifications/8da8f0b3-194a-4eed-95b0-43c1f1b64091.yml b/data/certifications/8da8f0b3-194a-4eed-95b0-43c1f1b64091.yml index c1a0d38..2191a37 100644 --- a/data/certifications/8da8f0b3-194a-4eed-95b0-43c1f1b64091.yml +++ b/data/certifications/8da8f0b3-194a-4eed-95b0-43c1f1b64091.yml @@ -9,7 +9,7 @@ badges: autonomous: - flags.autonomous.blue_0.AutonomousBlue0 - flags.autonomous.blue_1.AutonomousBlue1 - #- flags.autonomous.blue_2.AutonomousBlue2 + - flags.autonomous.blue_2.AutonomousBlue2 - flags.autonomous.blue_3.AutonomousBlue3 #manual: #- flags.manual.blue_0.ManualBlue0 diff --git a/tests/app/flags/__init__.py b/tests/app/flags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/flags/autonomous/__init__.py b/tests/app/flags/autonomous/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/flags/autonomous/test_blue_2.py b/tests/app/flags/autonomous/test_blue_2.py new file mode 100644 index 0000000..edd1dc3 --- /dev/null +++ b/tests/app/flags/autonomous/test_blue_2.py @@ -0,0 +1,90 @@ +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +import yaml + +from plugins.training.app.flags.autonomous.blue_2 import AutonomousBlue2 + +_PLUGIN_ROOT = Path(__file__).resolve().parents[4] + + +class TestAutonomousBlue2: + """Tests for the 'Malicious file on system' blue training flag (#167).""" + + def test_flag_importable_from_correct_path(self): + """The flag class must be importable from flags.autonomous.blue_2.""" + assert AutonomousBlue2 is not None + assert AutonomousBlue2.name == 'Malicious file on system' + + def test_flag_registered_in_blue_certificate_yaml(self): + """The flag must be uncommented and listed in the Blue Certificate YAML.""" + yaml_path = _PLUGIN_ROOT / 'data' / 'certifications' / '8da8f0b3-194a-4eed-95b0-43c1f1b64091.yml' + with open(yaml_path) as f: + cert = yaml.safe_load(f) + autonomous_flags = cert['badges']['autonomous'] + assert 'flags.autonomous.blue_2.AutonomousBlue2' in autonomous_flags + + def test_flag_has_no_visible_false(self): + """The flag should not have visible=False which would hide it from users.""" + assert not hasattr(AutonomousBlue2, 'visible') or AutonomousBlue2.visible is not False + + @pytest.mark.asyncio + async def test_verify_returns_true_when_file_found_and_deleted(self): + """Flag should be granted when the file is found AND deleted.""" + flag = AutonomousBlue2(number=1) + + mock_op = MagicMock() + mock_op.ran_ability_id = MagicMock(return_value=True) + + mock_fact_hash = MagicMock() + mock_fact_hash.trait = 'file.malicious.hash' + mock_fact_file = MagicMock() + mock_fact_file.trait = 'host.malicious.file' + mock_op.all_facts = AsyncMock(return_value=[mock_fact_hash, mock_fact_file]) + + mock_data_svc = AsyncMock() + mock_data_svc.locate = AsyncMock(return_value=[mock_op]) + services = {'data_svc': mock_data_svc} + + result = await flag.verify(services) + assert result is True + + @pytest.mark.asyncio + async def test_verify_returns_false_when_file_not_deleted(self): + """Flag should NOT be granted when file is found but not deleted.""" + flag = AutonomousBlue2(number=1) + + mock_op = MagicMock() + # ran_ability_id returns True for find, False for delete + def selective_ran(ability_id): + if ability_id == 'f9b3eff0-e11c-48de-9338-1578b351b14b': + return True # file found ability + return False # file delete ability not run + + mock_op.ran_ability_id = MagicMock(side_effect=selective_ran) + + mock_fact_hash = MagicMock() + mock_fact_hash.trait = 'file.malicious.hash' + mock_fact_file = MagicMock() + mock_fact_file.trait = 'host.malicious.file' + mock_op.all_facts = AsyncMock(return_value=[mock_fact_hash, mock_fact_file]) + + mock_data_svc = AsyncMock() + mock_data_svc.locate = AsyncMock(return_value=[mock_op]) + services = {'data_svc': mock_data_svc} + + result = await flag.verify(services) + assert result is False + + @pytest.mark.asyncio + async def test_verify_returns_false_when_no_operations(self): + """Flag should NOT be granted when there are no Blue Autonomous operations.""" + flag = AutonomousBlue2(number=1) + + mock_data_svc = AsyncMock() + mock_data_svc.locate = AsyncMock(return_value=[]) + services = {'data_svc': mock_data_svc} + + result = await flag.verify(services) + assert result is False