Skip to content

Commit 20bfc01

Browse files
uruwhyclenk
andauthored
make data_svc load_ability_file function more resilient (#3244)
* error handling for load_ability_file, update _get_plugin_name * unit tests for _get_plugin_name * style fix * ensure ability id is string * add unit test for load_ability_file * compare executors * proper none comparison * simplify eq method * test additional lines * address sonarcloud concerns --------- Co-authored-by: Chris Lenk <clenk@users.noreply.github.com>
1 parent 222b42a commit 20bfc01

File tree

3 files changed

+194
-28
lines changed

3 files changed

+194
-28
lines changed

app/objects/secondclass/c_executor.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ def __getattr__(self, item):
8383
def replace_cleanup(self, command, payload):
8484
return command.replace(self.RESERVED['payload'], payload)
8585

86+
def __eq__(self, other):
87+
"""Overrides the default eq implementation"""
88+
return isinstance(other, Executor) and self.name == other.name and self.platform == other.platform and \
89+
self.command == other.command and self.code == other.code and \
90+
self.language == other.language and self.build_target == other.build_target and \
91+
self.payloads == other.payloads and self.uploads == other.uploads and \
92+
self.timeout == other.timeout and self.parsers == other.parsers and \
93+
self.cleanup == other.cleanup and self.variations == other.variations and \
94+
self.additional_info == other.additional_info
95+
8696

8797
def get_variations(data):
8898
variations = []

app/service/data_svc.py

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -152,32 +152,41 @@ async def remove(self, object_name, match):
152152
self.log.error('[!] REMOVE: %s' % e)
153153

154154
async def load_ability_file(self, filename, access):
155-
for entries in self.strip_yml(filename):
156-
for ab in entries:
157-
ability_id = ab.pop('id', None)
158-
name = ab.pop('name', '')
159-
description = ab.pop('description', '')
160-
tactic = ab.pop('tactic', None)
161-
executors = await self.convert_v0_ability_executor(ab)
162-
technique_id = self.convert_v0_ability_technique_id(ab)
163-
technique_name = self.convert_v0_ability_technique_name(ab)
164-
privilege = ab.pop('privilege', None)
165-
repeatable = ab.pop('repeatable', False)
166-
singleton = ab.pop('singleton', False)
167-
requirements = await self.convert_v0_ability_requirements(ab.pop('requirements', []))
168-
buckets = ab.pop('buckets', [tactic])
169-
ab.pop('access', None)
170-
plugin = self._get_plugin_name(filename)
171-
ab.pop('plugin', plugin)
172-
173-
if tactic and tactic not in filename:
174-
self.log.error('Ability=%s has wrong tactic' % ability_id)
175-
176-
await self._create_ability(ability_id=ability_id, name=name, description=description, tactic=tactic,
177-
technique_id=technique_id, technique_name=technique_name,
178-
executors=executors, requirements=requirements, privilege=privilege,
179-
repeatable=repeatable, buckets=buckets, access=access, singleton=singleton, plugin=plugin,
180-
**ab)
155+
try:
156+
for entries in self.strip_yml(filename):
157+
for ab in entries:
158+
if type(ab) is not dict:
159+
self.log.error(f'Malformed ability file {filename}. Expected ability entry to be a dictionary, received {type(ab)} instead.')
160+
continue
161+
ability_id = ab.pop('id', None)
162+
if ability_id is not None and type(ability_id) is not str:
163+
ability_id = str(ability_id)
164+
name = ab.pop('name', '')
165+
description = ab.pop('description', '')
166+
tactic = ab.pop('tactic', None)
167+
executors = await self.convert_v0_ability_executor(ab)
168+
technique_id = self.convert_v0_ability_technique_id(ab)
169+
technique_name = self.convert_v0_ability_technique_name(ab)
170+
privilege = ab.pop('privilege', None)
171+
repeatable = ab.pop('repeatable', False)
172+
singleton = ab.pop('singleton', False)
173+
requirements = await self.convert_v0_ability_requirements(ab.pop('requirements', []))
174+
buckets = ab.pop('buckets', [tactic])
175+
ab.pop('access', None)
176+
plugin = self._get_plugin_name(filename)
177+
ab.pop('plugin', plugin)
178+
179+
if tactic and tactic not in filename:
180+
self.log.warn(f'Tactic for ability={ability_id} is not in the ability file path {filename}.')
181+
self.log.warn('Please check that the ability is labeled with the correct tactic and is in the correct location.')
182+
183+
await self._create_ability(ability_id=ability_id, name=name, description=description, tactic=tactic,
184+
technique_id=technique_id, technique_name=technique_name,
185+
executors=executors, requirements=requirements, privilege=privilege,
186+
repeatable=repeatable, buckets=buckets, access=access, singleton=singleton, plugin=plugin,
187+
**ab)
188+
except Exception as e:
189+
self.log.exception(f'Failed to load ability file {filename}: {e}')
181190

182191
async def convert_v0_ability_executor(self, ability_data: dict):
183192
"""Checks if ability file follows v0 executor format, otherwise assumes v1 ability formatting."""
@@ -502,8 +511,12 @@ async def _verify_adversary_profiles(self):
502511
adv.verify(log=self.log, abilities=self.ram['abilities'], objectives=self.ram['objectives'])
503512

504513
def _get_plugin_name(self, filename):
505-
plugin_path = pathlib.PurePath(filename).parts
506-
return plugin_path[1] if 'plugins' in plugin_path else ''
514+
path_components = pathlib.PurePath(filename).parts
515+
num_parts = len(path_components)
516+
for i, part in enumerate(path_components):
517+
if part == 'plugins' and i < num_parts - 1:
518+
return path_components[i + 1]
519+
return ''
507520

508521
async def get_facts_from_source(self, fact_source_id):
509522
fact_sources = await self.locate('sources', match=dict(id=fact_source_id))

tests/services/test_data_svc.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import glob
22
import json
3+
import logging
34
import yaml
45

56
from unittest import mock
@@ -76,10 +77,81 @@
7677
}
7778

7879

80+
ABILITY_YAMLS = {
81+
'plugins/testing/data/discovery/764efa883dda1e11db47671c4a3bbd9e.yml': [yaml.safe_load('''
82+
---
83+
84+
- id: 764efa883dda1e11db47671c4a3bbd9e
85+
name: Find deletable dirs (per user)
86+
description: Discover all directories containing deletable files by user
87+
tactic: discovery
88+
technique:
89+
attack_id: T1082
90+
name: System Information Discovery
91+
platforms:
92+
darwin:
93+
sh:
94+
command: |
95+
testcommand
96+
linux:
97+
sh:
98+
command: |
99+
testcommand
100+
''')],
101+
'plugins/testing/data/discovery/848aa201-4b00-4f08-ae3a-3e84dfb5065c.yml': [yaml.safe_load('''
102+
---
103+
104+
- id: 848aa201-4b00-4f08-ae3a-3e84dfb5065c
105+
name: Find deletable dirs (per user)
106+
description: Discover all directories containing deletable files by user
107+
tactic: discovery
108+
technique:
109+
attack_id: T1082
110+
name: System Information Discovery
111+
platforms:
112+
darwin:
113+
sh:
114+
command: |
115+
testcommand
116+
linux:
117+
sh:
118+
command: |
119+
testcommand
120+
''')],
121+
'plugins/testing/data/discovery/101.yml': [yaml.safe_load('''
122+
---
123+
124+
- id: 101
125+
name: Find deletable dirs (per user)
126+
description: Discover all directories containing deletable files by user
127+
tactic: purposefullywrongtactic
128+
technique:
129+
attack_id: T1082
130+
name: System Information Discovery
131+
platforms:
132+
darwin:
133+
sh:
134+
command: |
135+
testcommand
136+
linux:
137+
sh:
138+
command: |
139+
testcommand
140+
''')],
141+
'plugins/testing/data/discovery/102.yml': [yaml.safe_load('''
142+
malformed
143+
''')],
144+
}
145+
146+
79147
def strip_payload_yaml(path):
80148
return PAYLOAD_CONFIG_YAMLS.get(path, [])
81149

82150

151+
def strip_ability_yaml(path):
152+
return ABILITY_YAMLS.get(path, [])
153+
154+
83155
class TestDataService:
84156
mock_payload_config = dict()
85157

@@ -234,3 +306,74 @@ def _mock_apply_payload_config(config=None, **_):
234306
}
235307
}
236308
mock_apply_config2.assert_called_once_with(name='payloads', config=expected_config_part2)
309+
310+
@mock.patch.object(logging.Logger, 'warn')
311+
@mock.patch.object(BaseWorld, 'strip_yml', wraps=strip_ability_yaml)
312+
async def test_load_ability_file(self, mock_strip_yml, mock_warn, data_svc):
313+
want_executors = [
314+
Executor(name='sh', platform='darwin', command='testcommand',
315+
code=None, language=None, build_target=None,
316+
payloads=None, uploads=None, timeout=60,
317+
parsers=[], cleanup=None, variations=[]),
318+
Executor(name='sh', platform='linux', command='testcommand',
319+
code=None, language=None, build_target=None,
320+
payloads=None, uploads=None, timeout=60,
321+
parsers=[], cleanup=None, variations=[])
322+
]
323+
with patch.object(DataService, '_create_ability', return_value=None) as mock_create_ability:
324+
await data_svc.load_ability_file('plugins/testing/data/discovery/764efa883dda1e11db47671c4a3bbd9e.yml', BaseWorld.Access.RED)
325+
mock_create_ability.assert_called_once_with(ability_id='764efa883dda1e11db47671c4a3bbd9e', name='Find deletable dirs (per user)',
326+
description='Discover all directories containing deletable files by user',
327+
tactic='discovery', technique_id='T1082', technique_name='System Information Discovery',
328+
executors=want_executors, requirements=[], privilege=None,
329+
repeatable=False, buckets=['discovery'], access=BaseWorld.Access.RED, singleton=False, plugin='testing')
330+
331+
with patch.object(DataService, '_create_ability', return_value=None) as mock_create_ability:
332+
await data_svc.load_ability_file('plugins/testing/data/discovery/848aa201-4b00-4f08-ae3a-3e84dfb5065c.yml', BaseWorld.Access.RED)
333+
mock_create_ability.assert_called_once_with(ability_id='848aa201-4b00-4f08-ae3a-3e84dfb5065c', name='Find deletable dirs (per user)',
334+
description='Discover all directories containing deletable files by user',
335+
tactic='discovery', technique_id='T1082', technique_name='System Information Discovery',
336+
executors=want_executors, requirements=[], privilege=None,
337+
repeatable=False, buckets=['discovery'], access=BaseWorld.Access.RED, singleton=False, plugin='testing')
338+
339+
with patch.object(DataService, '_create_ability', return_value=None) as mock_create_ability:
340+
await data_svc.load_ability_file('plugins/testing/data/discovery/101.yml', BaseWorld.Access.RED)
341+
mock_warn.assert_any_call('Tactic for ability=101 is not in the ability file path plugins/testing/data/discovery/101.yml.')
342+
mock_warn.assert_called_with('Please check that the ability is labeled with the correct tactic and is in the correct location.')
343+
mock_create_ability.assert_called_once_with(ability_id='101', name='Find deletable dirs (per user)',
344+
description='Discover all directories containing deletable files by user',
345+
tactic='purposefullywrongtactic', technique_id='T1082', technique_name='System Information Discovery',
346+
executors=want_executors, requirements=[], privilege=None,
347+
repeatable=False, buckets=['purposefullywrongtactic'], access=BaseWorld.Access.RED, singleton=False, plugin='testing')
348+
349+
with patch.object(DataService, '_create_ability', return_value=None) as mock_create_ability:
350+
with patch.object(logging.Logger, 'error') as mock_error:
351+
await data_svc.load_ability_file('plugins/testing/data/discovery/102.yml', BaseWorld.Access.RED)
352+
mock_create_ability.assert_not_called()
353+
assert mock_error.called
354+
355+
# Test exception
356+
with patch.object(DataService, '_create_ability', side_effect=Exception('mockexception')):
357+
with patch.object(logging.Logger, 'exception') as mock_exception:
358+
await data_svc.load_ability_file('plugins/testing/data/discovery/101.yml', BaseWorld.Access.RED)
359+
mock_exception.assert_called_once_with(mock.ANY)
360+
assert 'Failed to load ability file plugins/testing/data/discovery/101.yml' in mock_exception.call_args.args[0]
361+
362+
def test_get_plugin_name(self, data_svc):
363+
assert 'test' == data_svc._get_plugin_name('plugins/test')
364+
assert 'test' == data_svc._get_plugin_name('plugins/test/')
365+
assert 'test' == data_svc._get_plugin_name('plugins/test/data')
366+
assert 'test' == data_svc._get_plugin_name('plugins/test/data/abilities')
367+
assert 'test' == data_svc._get_plugin_name('plugins/test/data/abilities/collection/123.yml')
368+
assert 'test' == data_svc._get_plugin_name('/full/path/to/plugins/test/data/abilities/collection/123.yml')
369+
assert '' == data_svc._get_plugin_name('test')
370+
assert '' == data_svc._get_plugin_name('plugins')
371+
assert '' == data_svc._get_plugin_name('plugins/')
372+
assert '' == data_svc._get_plugin_name('/full/path/to/plugins')
373+
assert '' == data_svc._get_plugin_name('/full/path/to/plugins/')
374+
assert '' == data_svc._get_plugin_name('plugin/test')
375+
assert '' == data_svc._get_plugin_name('plugin/test/')
376+
assert '' == data_svc._get_plugin_name('plugin/test/data')
377+
assert '' == data_svc._get_plugin_name('plugin/test/data/abilities')
378+
assert '' == data_svc._get_plugin_name('plugin/test/data/abilities/collection/123.yml')
379+
assert '' == data_svc._get_plugin_name('/full/path/to/plugin/test/data/abilities/collection/123.yml')

0 commit comments

Comments
 (0)