mal-toolbox 0.0.27__py3-none-any.whl → 0.1.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/METADATA +60 -28
- mal_toolbox-0.1.12.dist-info/RECORD +32 -0
- {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/WHEEL +1 -1
- maltoolbox/__init__.py +31 -31
- maltoolbox/__main__.py +80 -4
- maltoolbox/attackgraph/__init__.py +8 -0
- maltoolbox/attackgraph/analyzers/__init__.py +0 -0
- maltoolbox/attackgraph/analyzers/apriori.py +173 -27
- maltoolbox/attackgraph/attacker.py +99 -21
- maltoolbox/attackgraph/attackgraph.py +507 -217
- maltoolbox/attackgraph/node.py +143 -21
- maltoolbox/attackgraph/query.py +128 -26
- maltoolbox/default.conf +8 -7
- maltoolbox/exceptions.py +45 -0
- maltoolbox/file_utils.py +66 -0
- maltoolbox/ingestors/__init__.py +0 -0
- maltoolbox/ingestors/neo4j.py +95 -84
- maltoolbox/language/__init__.py +4 -0
- maltoolbox/language/classes_factory.py +145 -64
- maltoolbox/language/{lexer_parser/__main__.py → compiler/__init__.py} +5 -12
- maltoolbox/language/{lexer_parser → compiler}/mal_lexer.py +1 -1
- maltoolbox/language/{lexer_parser → compiler}/mal_parser.py +1 -1
- maltoolbox/language/{lexer_parser → compiler}/mal_visitor.py +4 -5
- maltoolbox/language/languagegraph.py +569 -168
- maltoolbox/model.py +858 -0
- maltoolbox/translators/__init__.py +0 -0
- maltoolbox/translators/securicad.py +76 -52
- maltoolbox/translators/updater.py +132 -0
- maltoolbox/wrappers.py +62 -0
- mal_toolbox-0.0.27.dist-info/RECORD +0 -26
- maltoolbox/cl_parser.py +0 -89
- maltoolbox/language/specification.py +0 -265
- maltoolbox/main.py +0 -84
- maltoolbox/model/model.py +0 -279
- {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/AUTHORS +0 -0
- {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/LICENSE +0 -0
- {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/top_level.txt +0 -0
|
@@ -1,26 +1,44 @@
|
|
|
1
1
|
"""
|
|
2
2
|
MAL-Toolbox Attack Graph Module
|
|
3
3
|
"""
|
|
4
|
-
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import copy
|
|
5
6
|
import logging
|
|
6
7
|
import json
|
|
7
8
|
|
|
8
|
-
from typing import
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from .node import AttackGraphNode
|
|
12
|
+
from .attacker import Attacker
|
|
13
|
+
from ..exceptions import AttackGraphStepExpressionError
|
|
14
|
+
from ..model import Model
|
|
15
|
+
from ..exceptions import AttackGraphException
|
|
16
|
+
from ..file_utils import (
|
|
17
|
+
load_dict_from_json_file,
|
|
18
|
+
load_dict_from_yaml_file,
|
|
19
|
+
save_dict_to_file
|
|
20
|
+
)
|
|
9
21
|
|
|
10
|
-
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from maltoolbox.attackgraph import attacker
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
from ..language import LanguageGraph
|
|
14
25
|
|
|
15
26
|
logger = logging.getLogger(__name__)
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
# TODO see if (part of) this can be incorporated into the LanguageGraph, so that
|
|
29
|
+
# the LanguageGraph's _lang_spec private property does not need to be accessed
|
|
30
|
+
def _process_step_expression(
|
|
31
|
+
lang_graph: LanguageGraph,
|
|
32
|
+
model: Model,
|
|
33
|
+
target_assets: list[Any],
|
|
34
|
+
step_expression: dict[str, Any]
|
|
35
|
+
) -> tuple[list, Optional[str]]:
|
|
19
36
|
"""
|
|
20
37
|
Recursively process an attack step expression.
|
|
21
38
|
|
|
22
39
|
Arguments:
|
|
23
|
-
|
|
40
|
+
lang_graph - a language graph representing the MAL language
|
|
41
|
+
specification
|
|
24
42
|
model - a maltoolbox.model.Model instance from which the attack
|
|
25
43
|
graph was generated
|
|
26
44
|
target_assets - the list of assets that this step expression should apply
|
|
@@ -32,8 +50,13 @@ def _process_step_expression(lang: dict, model: model.Model,
|
|
|
32
50
|
A tuple pair containing a list of all of the target assets and the name of
|
|
33
51
|
the attack step.
|
|
34
52
|
"""
|
|
35
|
-
|
|
36
|
-
|
|
53
|
+
|
|
54
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
55
|
+
# Avoid running json.dumps when not in debug
|
|
56
|
+
logger.debug(
|
|
57
|
+
'Processing Step Expression:\n%s',
|
|
58
|
+
json.dumps(step_expression, indent = 2)
|
|
59
|
+
)
|
|
37
60
|
|
|
38
61
|
match (step_expression['type']):
|
|
39
62
|
case 'attackStep':
|
|
@@ -45,9 +68,9 @@ def _process_step_expression(lang: dict, model: model.Model,
|
|
|
45
68
|
# The set operators are used to combine the left hand and right
|
|
46
69
|
# hand targets accordingly.
|
|
47
70
|
lh_targets, lh_attack_steps = _process_step_expression(
|
|
48
|
-
|
|
71
|
+
lang_graph, model, target_assets, step_expression['lhs'])
|
|
49
72
|
rh_targets, rh_attack_steps = _process_step_expression(
|
|
50
|
-
|
|
73
|
+
lang_graph, model, target_assets, step_expression['rhs'])
|
|
51
74
|
|
|
52
75
|
new_target_assets = []
|
|
53
76
|
match (step_expression['type']):
|
|
@@ -60,7 +83,7 @@ def _process_step_expression(lang: dict, model: model.Model,
|
|
|
60
83
|
|
|
61
84
|
case 'intersection':
|
|
62
85
|
for ag_node in rh_targets:
|
|
63
|
-
if next((lnode for lnode in
|
|
86
|
+
if next((lnode for lnode in lh_targets \
|
|
64
87
|
if lnode.id == ag_node.id), None):
|
|
65
88
|
new_target_assets.append(ag_node)
|
|
66
89
|
|
|
@@ -77,17 +100,19 @@ def _process_step_expression(lang: dict, model: model.Model,
|
|
|
77
100
|
# Fetch the step expression associated with the variable from
|
|
78
101
|
# the language specification and resolve that.
|
|
79
102
|
for target_asset in target_assets:
|
|
80
|
-
if (hasattr(target_asset, '
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
if (hasattr(target_asset, 'type')):
|
|
104
|
+
# TODO how can this info be accessed in the lang_graph
|
|
105
|
+
# directly without going through the private method?
|
|
106
|
+
variable_step_expr = lang_graph._get_variable_for_asset_type_by_name(
|
|
107
|
+
target_asset.type, step_expression['name'])
|
|
84
108
|
return _process_step_expression(
|
|
85
|
-
|
|
109
|
+
lang_graph, model, target_assets, variable_step_expr)
|
|
86
110
|
|
|
87
111
|
else:
|
|
88
|
-
logger.error(
|
|
89
|
-
|
|
90
|
-
'resolved.'
|
|
112
|
+
logger.error(
|
|
113
|
+
'Requested variable from non-asset target node:'
|
|
114
|
+
'%s which cannot be resolved.', target_asset
|
|
115
|
+
)
|
|
91
116
|
return ([], None)
|
|
92
117
|
|
|
93
118
|
case 'field':
|
|
@@ -112,7 +137,7 @@ def _process_step_expression(lang: dict, model: model.Model,
|
|
|
112
137
|
step_expression['stepExpression']['name']))
|
|
113
138
|
if new_target_assets:
|
|
114
139
|
(additional_assets, _) = _process_step_expression(
|
|
115
|
-
|
|
140
|
+
lang_graph, model, new_target_assets, step_expression)
|
|
116
141
|
new_target_assets.extend(additional_assets)
|
|
117
142
|
return (new_target_assets, None)
|
|
118
143
|
else:
|
|
@@ -122,91 +147,181 @@ def _process_step_expression(lang: dict, model: model.Model,
|
|
|
122
147
|
new_target_assets = []
|
|
123
148
|
for target_asset in target_assets:
|
|
124
149
|
(assets, _) = _process_step_expression(
|
|
125
|
-
|
|
150
|
+
lang_graph, model, target_assets,
|
|
151
|
+
step_expression['stepExpression'])
|
|
126
152
|
new_target_assets.extend(assets)
|
|
127
153
|
|
|
128
|
-
selected_new_target_assets =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
asset.
|
|
132
|
-
|
|
154
|
+
selected_new_target_assets = []
|
|
155
|
+
for asset in new_target_assets:
|
|
156
|
+
lang_graph_asset = lang_graph.get_asset_by_name(
|
|
157
|
+
asset.type
|
|
158
|
+
)
|
|
159
|
+
if not lang_graph_asset:
|
|
160
|
+
raise LookupError(
|
|
161
|
+
f'Failed to find asset \"{asset.type}\" in the '
|
|
162
|
+
'language graph.'
|
|
163
|
+
)
|
|
164
|
+
lang_graph_subtype_asset = lang_graph.get_asset_by_name(
|
|
165
|
+
step_expression['subType']
|
|
166
|
+
)
|
|
167
|
+
if not lang_graph_subtype_asset:
|
|
168
|
+
raise LookupError(
|
|
169
|
+
'Failed to find asset '
|
|
170
|
+
f'\"{step_expression["subType"]}\" in the '
|
|
171
|
+
'language graph.'
|
|
172
|
+
)
|
|
173
|
+
if lang_graph_asset.is_subasset_of(lang_graph_subtype_asset):
|
|
174
|
+
selected_new_target_assets.append(asset)
|
|
175
|
+
|
|
133
176
|
return (selected_new_target_assets, None)
|
|
134
177
|
|
|
135
178
|
case 'collect':
|
|
136
179
|
# Apply the right hand step expression to left hand step
|
|
137
180
|
# expression target assets.
|
|
138
181
|
lh_targets, _ = _process_step_expression(
|
|
139
|
-
|
|
140
|
-
return _process_step_expression(
|
|
182
|
+
lang_graph, model, target_assets, step_expression['lhs'])
|
|
183
|
+
return _process_step_expression(lang_graph, model, lh_targets,
|
|
141
184
|
step_expression['rhs'])
|
|
142
185
|
|
|
143
186
|
|
|
144
187
|
case _:
|
|
145
|
-
logger.error(
|
|
146
|
-
|
|
188
|
+
logger.error(
|
|
189
|
+
'Unknown attack step type: %s', step_expression["type"]
|
|
190
|
+
)
|
|
147
191
|
return ([], None)
|
|
148
192
|
|
|
193
|
+
class AttackGraph():
|
|
194
|
+
"""Graph representation of attack steps"""
|
|
195
|
+
def __init__(self, lang_graph = None, model: Optional[Model] = None):
|
|
196
|
+
self.nodes: list[AttackGraphNode] = []
|
|
197
|
+
self.attackers: list[Attacker] = []
|
|
198
|
+
# Dictionaries used in optimization to get nodes and attackers by id
|
|
199
|
+
# or full name faster
|
|
200
|
+
self._id_to_node: dict[int, AttackGraphNode] = {}
|
|
201
|
+
self._full_name_to_node: dict[str, AttackGraphNode] = {}
|
|
202
|
+
self._id_to_attacker: dict[int, Attacker] = {}
|
|
149
203
|
|
|
150
|
-
|
|
151
|
-
class AttackGraph:
|
|
152
|
-
def __init__(self, lang_spec = None, model: Optional[model.Model] = None):
|
|
153
|
-
self.nodes = []
|
|
154
|
-
self.attackers = []
|
|
155
204
|
self.model = model
|
|
156
|
-
self.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
logger.info(f'Saving attack graph with {len(self.nodes)} attack step '
|
|
170
|
-
f'nodes to {filename} file.')
|
|
171
|
-
serialized_graph = []
|
|
205
|
+
self.lang_graph = lang_graph
|
|
206
|
+
self.next_node_id = 0
|
|
207
|
+
self.next_attacker_id = 0
|
|
208
|
+
if self.model is not None and self.lang_graph is not None:
|
|
209
|
+
self._generate_graph()
|
|
210
|
+
|
|
211
|
+
def __repr__(self) -> str:
|
|
212
|
+
return f'AttackGraph({len(self.nodes)} nodes)'
|
|
213
|
+
|
|
214
|
+
def _to_dict(self) -> dict:
|
|
215
|
+
"""Convert AttackGraph to dict"""
|
|
216
|
+
serialized_attack_steps = {}
|
|
217
|
+
serialized_attackers = {}
|
|
172
218
|
for ag_node in self.nodes:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
219
|
+
serialized_attack_steps[ag_node.full_name] =\
|
|
220
|
+
ag_node.to_dict()
|
|
221
|
+
for attacker in self.attackers:
|
|
222
|
+
serialized_attackers[attacker.name] = attacker.to_dict()
|
|
223
|
+
return {
|
|
224
|
+
'attack_steps': serialized_attack_steps,
|
|
225
|
+
'attackers': serialized_attackers,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
def __deepcopy__(self, memo):
|
|
229
|
+
|
|
230
|
+
# Check if the object is already in the memo dictionary
|
|
231
|
+
if id(self) in memo:
|
|
232
|
+
return memo[id(self)]
|
|
233
|
+
|
|
234
|
+
copied_attackgraph = AttackGraph(self.lang_graph)
|
|
235
|
+
copied_attackgraph.model = self.model
|
|
236
|
+
|
|
237
|
+
copied_attackgraph.nodes = []
|
|
238
|
+
|
|
239
|
+
# Deep copy nodes
|
|
240
|
+
for node in self.nodes:
|
|
241
|
+
copied_node = copy.deepcopy(node, memo)
|
|
242
|
+
copied_attackgraph.nodes.append(copied_node)
|
|
243
|
+
|
|
244
|
+
# Re-link node references
|
|
245
|
+
for node in self.nodes:
|
|
246
|
+
if node.parents:
|
|
247
|
+
memo[id(node)].parents = copy.deepcopy(node.parents, memo)
|
|
248
|
+
if node.children:
|
|
249
|
+
memo[id(node)].children = copy.deepcopy(node.children, memo)
|
|
250
|
+
|
|
251
|
+
# Deep copy attackers and references to them
|
|
252
|
+
copied_attackgraph.attackers = copy.deepcopy(self.attackers, memo)
|
|
253
|
+
|
|
254
|
+
# Re-link attacker references
|
|
255
|
+
for node in self.nodes:
|
|
256
|
+
if node.compromised_by:
|
|
257
|
+
memo[id(node)].compromised_by = copy.deepcopy(
|
|
258
|
+
node.compromised_by, memo)
|
|
259
|
+
|
|
260
|
+
# Copy lookup dicts
|
|
261
|
+
copied_attackgraph._id_to_attacker = \
|
|
262
|
+
copy.deepcopy(self._id_to_attacker, memo)
|
|
263
|
+
copied_attackgraph._id_to_node = \
|
|
264
|
+
copy.deepcopy(self._id_to_node, memo)
|
|
265
|
+
copied_attackgraph._full_name_to_node = \
|
|
266
|
+
copy.deepcopy(self._full_name_to_node, memo)
|
|
267
|
+
|
|
268
|
+
# Copy counters
|
|
269
|
+
copied_attackgraph.next_node_id = self.next_node_id
|
|
270
|
+
copied_attackgraph.next_attacker_id = self.next_attacker_id
|
|
271
|
+
|
|
272
|
+
return copied_attackgraph
|
|
273
|
+
|
|
274
|
+
def save_to_file(self, filename: str) -> None:
|
|
275
|
+
"""Save to json/yml depending on extension"""
|
|
276
|
+
logger.debug('Save attack graph to file "%s".', filename)
|
|
277
|
+
return save_dict_to_file(filename, self._to_dict())
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def _from_dict(
|
|
281
|
+
cls,
|
|
282
|
+
serialized_object: dict,
|
|
283
|
+
model: Optional[Model]=None
|
|
284
|
+
) -> AttackGraph:
|
|
285
|
+
"""Create AttackGraph from dict
|
|
286
|
+
Args:
|
|
287
|
+
serialized_object - AttackGraph in dict format
|
|
288
|
+
model - Optional Model to add connections to
|
|
189
289
|
"""
|
|
190
290
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
else:
|
|
196
|
-
logger.info('No model was provided therefore asset links will '
|
|
197
|
-
'not be established.')
|
|
291
|
+
attack_graph = AttackGraph()
|
|
292
|
+
attack_graph.model = model
|
|
293
|
+
serialized_attack_steps = serialized_object['attack_steps']
|
|
294
|
+
serialized_attackers = serialized_object['attackers']
|
|
198
295
|
|
|
199
|
-
with open(filename, 'r', encoding='utf-8') as file:
|
|
200
|
-
serialized_graph = json.load(file)
|
|
201
296
|
# Create all of the nodes in the imported attack graph.
|
|
202
|
-
for node_dict in
|
|
203
|
-
|
|
204
|
-
|
|
297
|
+
for node_full_name, node_dict in serialized_attack_steps.items():
|
|
298
|
+
|
|
299
|
+
# Recreate asset links if model is available.
|
|
300
|
+
node_asset = None
|
|
301
|
+
if model and 'asset' in node_dict:
|
|
302
|
+
node_asset = model.get_asset_by_name(node_dict['asset'])
|
|
303
|
+
if node_asset is None:
|
|
304
|
+
msg = ('Failed to find asset with id %s'
|
|
305
|
+
'when loading from attack graph dict')
|
|
306
|
+
logger.error(msg, node_dict["asset"])
|
|
307
|
+
raise LookupError(msg % node_dict["asset"])
|
|
308
|
+
|
|
309
|
+
ag_node = AttackGraphNode(
|
|
205
310
|
type=node_dict['type'],
|
|
206
311
|
name=node_dict['name'],
|
|
207
|
-
ttc=node_dict['ttc']
|
|
312
|
+
ttc=node_dict['ttc'],
|
|
313
|
+
asset=node_asset
|
|
208
314
|
)
|
|
209
315
|
|
|
316
|
+
if node_asset:
|
|
317
|
+
# Add AttackGraphNode to attack_step_nodes of asset
|
|
318
|
+
if hasattr(node_asset, 'attack_step_nodes'):
|
|
319
|
+
node_attack_steps = list(node_asset.attack_step_nodes)
|
|
320
|
+
node_attack_steps.append(ag_node)
|
|
321
|
+
node_asset.attack_step_nodes = node_attack_steps
|
|
322
|
+
else:
|
|
323
|
+
node_asset.attack_step_nodes = [ag_node]
|
|
324
|
+
|
|
210
325
|
ag_node.defense_status = float(node_dict['defense_status']) if \
|
|
211
326
|
'defense_status' in node_dict else None
|
|
212
327
|
ag_node.existence_status = node_dict['existence_status'] \
|
|
@@ -218,180 +333,214 @@ class AttackGraph:
|
|
|
218
333
|
ag_node.mitre_info = str(node_dict['mitre_info']) if \
|
|
219
334
|
'mitre_info' in node_dict else None
|
|
220
335
|
ag_node.tags = node_dict['tags'] if \
|
|
221
|
-
'tags' in node_dict else
|
|
222
|
-
|
|
223
|
-
# This is an attacker entry point node, recreate the attacker.
|
|
224
|
-
attacker_id = ag_node.id.split(':')[1]
|
|
225
|
-
ag_attacker = attacker.Attacker(
|
|
226
|
-
id = str(attacker_id),
|
|
227
|
-
entry_points = [],
|
|
228
|
-
reached_attack_steps = [],
|
|
229
|
-
node = ag_node
|
|
230
|
-
)
|
|
231
|
-
self.attackers.append(ag_attacker)
|
|
232
|
-
ag_node.attacker = ag_attacker
|
|
336
|
+
'tags' in node_dict else []
|
|
337
|
+
ag_node.extras = node_dict.get('extras', {})
|
|
233
338
|
|
|
234
|
-
|
|
339
|
+
# Add AttackGraphNode to AttackGraph
|
|
340
|
+
attack_graph.add_node(ag_node, node_id=node_dict['id'])
|
|
235
341
|
|
|
236
342
|
# Re-establish links between nodes.
|
|
237
|
-
for node_dict in
|
|
238
|
-
_ag_node
|
|
239
|
-
if not isinstance(_ag_node,
|
|
240
|
-
|
|
241
|
-
|
|
343
|
+
for node_full_name, node_dict in serialized_attack_steps.items():
|
|
344
|
+
_ag_node = attack_graph.get_node_by_id(node_dict['id'])
|
|
345
|
+
if not isinstance(_ag_node, AttackGraphNode):
|
|
346
|
+
msg = ('Failed to find node with id %s when loading'
|
|
347
|
+
' attack graph from dict')
|
|
348
|
+
logger.error(msg, node_dict["id"])
|
|
349
|
+
raise LookupError(msg % node_dict["id"])
|
|
242
350
|
else:
|
|
243
351
|
for child_id in node_dict['children']:
|
|
244
|
-
child =
|
|
352
|
+
child = attack_graph.get_node_by_id(int(child_id))
|
|
245
353
|
if child is None:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
354
|
+
msg = ('Failed to find child node with id %s'
|
|
355
|
+
' when loading from attack graph from dict')
|
|
356
|
+
logger.error(msg, child_id)
|
|
357
|
+
raise LookupError(msg % child_id)
|
|
249
358
|
_ag_node.children.append(child)
|
|
250
359
|
|
|
251
|
-
if isinstance(_ag_node.attacker, attacker.Attacker):
|
|
252
|
-
# Relink the attacker related connections since the node
|
|
253
|
-
# is an attacker entry point node.
|
|
254
|
-
ag_attacker = _ag_node.attacker
|
|
255
|
-
ag_attacker.entry_points.append(child)
|
|
256
|
-
ag_attacker.compromise(child)
|
|
257
|
-
|
|
258
360
|
for parent_id in node_dict['parents']:
|
|
259
|
-
parent =
|
|
361
|
+
parent = attack_graph.get_node_by_id(int(parent_id))
|
|
260
362
|
if parent is None:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
363
|
+
msg = ('Failed to find parent node with id %s '
|
|
364
|
+
'when loading from attack graph from dict')
|
|
365
|
+
logger.error(msg, parent_id)
|
|
366
|
+
raise LookupError(msg % parent_id)
|
|
265
367
|
_ag_node.parents.append(parent)
|
|
266
368
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
369
|
+
for attacker_name, attacker in serialized_attackers.items():
|
|
370
|
+
ag_attacker = Attacker(
|
|
371
|
+
name = attacker['name'],
|
|
372
|
+
entry_points = [],
|
|
373
|
+
reached_attack_steps = []
|
|
374
|
+
)
|
|
375
|
+
attack_graph.add_attacker(
|
|
376
|
+
attacker = ag_attacker,
|
|
377
|
+
attacker_id = int(attacker['id']),
|
|
378
|
+
entry_points = attacker['entry_points'].keys(),
|
|
379
|
+
reached_attack_steps = [
|
|
380
|
+
int(node_id) # Convert to int since they can be strings
|
|
381
|
+
for node_id in attacker['reached_attack_steps'].keys()
|
|
382
|
+
]
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return attack_graph
|
|
386
|
+
|
|
387
|
+
@classmethod
|
|
388
|
+
def load_from_file(
|
|
389
|
+
cls,
|
|
390
|
+
filename: str,
|
|
391
|
+
model: Optional[Model]=None
|
|
392
|
+
) -> AttackGraph:
|
|
393
|
+
"""Create from json or yaml file depending on file extension"""
|
|
394
|
+
if model is not None:
|
|
395
|
+
logger.debug('Load attack graph from file "%s" with '
|
|
396
|
+
'model "%s".', filename, model.name)
|
|
397
|
+
else:
|
|
398
|
+
logger.debug('Load attack graph from file "%s" '
|
|
399
|
+
'without model.', filename)
|
|
400
|
+
serialized_attack_graph = None
|
|
401
|
+
if filename.endswith(('.yml', '.yaml')):
|
|
402
|
+
serialized_attack_graph = load_dict_from_yaml_file(filename)
|
|
403
|
+
elif filename.endswith('.json'):
|
|
404
|
+
serialized_attack_graph = load_dict_from_json_file(filename)
|
|
405
|
+
else:
|
|
406
|
+
raise ValueError('Unknown file extension, expected json/yml/yaml')
|
|
407
|
+
return cls._from_dict(serialized_attack_graph, model=model)
|
|
408
|
+
|
|
409
|
+
def get_node_by_id(self, node_id: int) -> Optional[AttackGraphNode]:
|
|
286
410
|
"""
|
|
287
411
|
Return the attack node that matches the id provided.
|
|
288
412
|
|
|
289
413
|
Arguments:
|
|
290
|
-
node_id - the id of the attack graph
|
|
414
|
+
node_id - the id of the attack graph node we are looking for
|
|
291
415
|
|
|
292
416
|
Return:
|
|
293
417
|
The attack step node that matches the given id.
|
|
294
418
|
"""
|
|
295
419
|
|
|
296
|
-
logger.debug(
|
|
297
|
-
return
|
|
298
|
-
|
|
420
|
+
logger.debug('Looking up node with id %s', node_id)
|
|
421
|
+
return self._id_to_node.get(node_id)
|
|
422
|
+
|
|
423
|
+
def get_node_by_full_name(self, full_name: str) -> Optional[AttackGraphNode]:
|
|
424
|
+
"""
|
|
425
|
+
Return the attack node that matches the full name provided.
|
|
299
426
|
|
|
427
|
+
Arguments:
|
|
428
|
+
full_name - the full name of the attack graph node we are looking
|
|
429
|
+
for
|
|
300
430
|
|
|
301
|
-
|
|
431
|
+
Return:
|
|
432
|
+
The attack step node that matches the given full name.
|
|
302
433
|
"""
|
|
303
|
-
|
|
304
|
-
|
|
434
|
+
|
|
435
|
+
logger.debug(f'Looking up node with full name "{full_name}"')
|
|
436
|
+
return self._full_name_to_node.get(full_name)
|
|
437
|
+
|
|
438
|
+
def get_attacker_by_id(self, attacker_id: int) -> Optional[Attacker]:
|
|
439
|
+
"""
|
|
440
|
+
Return the attacker that matches the id provided.
|
|
305
441
|
|
|
306
442
|
Arguments:
|
|
307
|
-
|
|
443
|
+
attacker_id - the id of the attacker we are looking for
|
|
444
|
+
|
|
445
|
+
Return:
|
|
446
|
+
The attacker that matches the given id.
|
|
308
447
|
"""
|
|
309
448
|
|
|
310
|
-
logger.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
)
|
|
449
|
+
logger.debug(f'Looking up attacker with id {attacker_id}')
|
|
450
|
+
return self._id_to_attacker.get(attacker_id)
|
|
451
|
+
|
|
452
|
+
def attach_attackers(self) -> None:
|
|
453
|
+
"""
|
|
454
|
+
Create attackers and their entry point nodes and attach them to the
|
|
455
|
+
relevant attack step nodes and to the attackers.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
if not self.model:
|
|
459
|
+
msg = "Can not attach attackers without a model"
|
|
460
|
+
logger.error(msg)
|
|
461
|
+
raise AttackGraphException(msg)
|
|
462
|
+
|
|
463
|
+
logger.info(
|
|
464
|
+
'Attach attackers from "%s" model to the graph.', self.model.name
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
for attacker_info in self.model.attackers:
|
|
323
468
|
|
|
324
|
-
|
|
325
|
-
|
|
469
|
+
if not attacker_info.name:
|
|
470
|
+
msg = "Can not attach attacker without name"
|
|
471
|
+
logger.error(msg)
|
|
472
|
+
raise AttackGraphException(msg)
|
|
473
|
+
|
|
474
|
+
attacker = Attacker(
|
|
475
|
+
name = attacker_info.name,
|
|
326
476
|
entry_points = [],
|
|
327
|
-
reached_attack_steps = []
|
|
328
|
-
node = attacker_node
|
|
477
|
+
reached_attack_steps = []
|
|
329
478
|
)
|
|
330
|
-
|
|
331
|
-
self.attackers.append(ag_attacker)
|
|
479
|
+
self.add_attacker(attacker)
|
|
332
480
|
|
|
333
481
|
for (asset, attack_steps) in attacker_info.entry_points:
|
|
334
482
|
for attack_step in attack_steps:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
ag_node = self.get_node_by_id(attack_step_id)
|
|
483
|
+
full_name = asset.name + ':' + attack_step
|
|
484
|
+
ag_node = self.get_node_by_full_name(full_name)
|
|
338
485
|
if not ag_node:
|
|
339
|
-
logger.warning(
|
|
340
|
-
|
|
341
|
-
|
|
486
|
+
logger.warning(
|
|
487
|
+
'Failed to find attacker entry point '
|
|
488
|
+
'%s for %s.',
|
|
489
|
+
full_name, attacker.name
|
|
490
|
+
)
|
|
342
491
|
continue
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
ag_attacker.entry_points = ag_attacker.reached_attack_steps
|
|
346
|
-
attacker_node.children = ag_attacker.entry_points
|
|
347
|
-
self.nodes.append(attacker_node)
|
|
492
|
+
attacker.compromise(ag_node)
|
|
348
493
|
|
|
494
|
+
attacker.entry_points = list(attacker.reached_attack_steps)
|
|
349
495
|
|
|
350
|
-
def
|
|
496
|
+
def _generate_graph(self) -> None:
|
|
351
497
|
"""
|
|
352
|
-
Generate attack graph
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
Arguments:
|
|
356
|
-
model - a maltoolbox.model.Model instance
|
|
357
|
-
lang - a dictionary representing the MAL language specification
|
|
498
|
+
Generate the attack graph based on the original model instance and the
|
|
499
|
+
MAL language specification provided at initialization.
|
|
358
500
|
"""
|
|
359
501
|
|
|
360
|
-
if
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if self.model is None or self.lang_spec is None:
|
|
365
|
-
return
|
|
502
|
+
if not self.model:
|
|
503
|
+
msg = "Can not generate AttackGraph without model"
|
|
504
|
+
logger.error(msg)
|
|
505
|
+
raise AttackGraphException(msg)
|
|
366
506
|
|
|
367
507
|
# First, generate all of the nodes of the attack graph.
|
|
368
508
|
for asset in self.model.assets:
|
|
369
|
-
|
|
370
|
-
|
|
509
|
+
|
|
510
|
+
logger.debug(
|
|
511
|
+
'Generating attack steps for asset %s which is of class %s.',
|
|
512
|
+
asset.name, asset.type
|
|
513
|
+
)
|
|
514
|
+
|
|
371
515
|
attack_step_nodes = []
|
|
372
|
-
|
|
373
|
-
|
|
516
|
+
|
|
517
|
+
# TODO probably part of what happens here is already done in lang_graph
|
|
518
|
+
attack_steps = self.lang_graph._get_attacks_for_asset_type(asset.type)
|
|
519
|
+
|
|
374
520
|
for attack_step_name, attack_step_attribs in attack_steps.items():
|
|
375
|
-
logger.debug(
|
|
376
|
-
|
|
521
|
+
logger.debug(
|
|
522
|
+
'Generating attack step node for %s.', attack_step_name
|
|
523
|
+
)
|
|
377
524
|
|
|
378
525
|
defense_status = None
|
|
379
|
-
existence_status
|
|
380
|
-
|
|
526
|
+
existence_status = None
|
|
527
|
+
node_name = asset.name + ':' + attack_step_name
|
|
381
528
|
|
|
382
529
|
match (attack_step_attribs['type']):
|
|
383
530
|
case 'defense':
|
|
384
531
|
# Set the defense status for defenses
|
|
385
532
|
defense_status = getattr(asset, attack_step_name)
|
|
386
|
-
logger.debug(
|
|
387
|
-
|
|
533
|
+
logger.debug(
|
|
534
|
+
'Setting the defense status of %s to %s.',
|
|
535
|
+
node_name, defense_status
|
|
536
|
+
)
|
|
388
537
|
|
|
389
538
|
case 'exist' | 'notExist':
|
|
390
539
|
# Resolve step expression associated with (non-)existence
|
|
391
540
|
# attack steps.
|
|
392
541
|
(target_assets, attack_step) = _process_step_expression(
|
|
393
|
-
|
|
394
|
-
model,
|
|
542
|
+
self.lang_graph,
|
|
543
|
+
self.model,
|
|
395
544
|
[asset],
|
|
396
545
|
attack_step_attribs['requires']['stepExpressions'][0])
|
|
397
546
|
# If the step expression resolution yielded the target
|
|
@@ -400,8 +549,7 @@ class AttackGraph:
|
|
|
400
549
|
|
|
401
550
|
mitre_info = attack_step_attribs['meta']['mitre'] if 'mitre' in\
|
|
402
551
|
attack_step_attribs['meta'] else None
|
|
403
|
-
ag_node =
|
|
404
|
-
id = node_id,
|
|
552
|
+
ag_node = AttackGraphNode(
|
|
405
553
|
type = attack_step_attribs['type'],
|
|
406
554
|
asset = asset,
|
|
407
555
|
name = attack_step_name,
|
|
@@ -418,13 +566,16 @@ class AttackGraph:
|
|
|
418
566
|
)
|
|
419
567
|
ag_node.attributes = attack_step_attribs
|
|
420
568
|
attack_step_nodes.append(ag_node)
|
|
421
|
-
self.
|
|
569
|
+
self.add_node(ag_node)
|
|
422
570
|
asset.attack_step_nodes = attack_step_nodes
|
|
423
571
|
|
|
424
572
|
# Then, link all of the nodes according to their associations.
|
|
425
573
|
for ag_node in self.nodes:
|
|
426
|
-
logger.debug(
|
|
427
|
-
|
|
574
|
+
logger.debug(
|
|
575
|
+
'Determining children for attack step "%s"(%d)',
|
|
576
|
+
ag_node.full_name,
|
|
577
|
+
ag_node.id
|
|
578
|
+
)
|
|
428
579
|
step_expressions = \
|
|
429
580
|
ag_node.attributes['reaches']['stepExpressions'] if \
|
|
430
581
|
isinstance(ag_node.attributes, dict) and ag_node.attributes['reaches'] else []
|
|
@@ -432,21 +583,160 @@ class AttackGraph:
|
|
|
432
583
|
for step_expression in step_expressions:
|
|
433
584
|
# Resolve each of the attack step expressions listed for this
|
|
434
585
|
# attack step to determine children.
|
|
435
|
-
(target_assets, attack_step) = _process_step_expression(
|
|
436
|
-
|
|
586
|
+
(target_assets, attack_step) = _process_step_expression(
|
|
587
|
+
self.lang_graph,
|
|
588
|
+
self.model,
|
|
589
|
+
[ag_node.asset],
|
|
590
|
+
step_expression)
|
|
591
|
+
|
|
437
592
|
for target in target_assets:
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
593
|
+
target_node_full_name = target.name + ':' + attack_step
|
|
594
|
+
target_node = self.get_node_by_full_name(
|
|
595
|
+
target_node_full_name
|
|
596
|
+
)
|
|
441
597
|
if not target_node:
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
598
|
+
msg = ('Failed to find target node '
|
|
599
|
+
'"%s" to link with for attack step "%s"(%d)!')
|
|
600
|
+
logger.error(
|
|
601
|
+
msg,
|
|
602
|
+
target_node_full_name,
|
|
603
|
+
ag_node.full_name,
|
|
604
|
+
ag_node.id
|
|
605
|
+
)
|
|
606
|
+
raise AttackGraphStepExpressionError(
|
|
607
|
+
msg % (
|
|
608
|
+
target_node_full_name,
|
|
609
|
+
ag_node.full_name,
|
|
610
|
+
ag_node.id
|
|
611
|
+
)
|
|
612
|
+
)
|
|
449
613
|
ag_node.children.append(target_node)
|
|
450
614
|
target_node.parents.append(ag_node)
|
|
451
615
|
|
|
452
|
-
|
|
616
|
+
def regenerate_graph(self) -> None:
|
|
617
|
+
"""
|
|
618
|
+
Regenerate the attack graph based on the original model instance and
|
|
619
|
+
the MAL language specification provided at initialization.
|
|
620
|
+
"""
|
|
621
|
+
|
|
622
|
+
self.nodes = []
|
|
623
|
+
self.attackers = []
|
|
624
|
+
self._generate_graph()
|
|
625
|
+
|
|
626
|
+
def add_node(
|
|
627
|
+
self,
|
|
628
|
+
node: AttackGraphNode,
|
|
629
|
+
node_id: Optional[int] = None
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Add a node to the graph
|
|
632
|
+
Arguments:
|
|
633
|
+
node - the node to add
|
|
634
|
+
node_id - the id to assign to this node, usually used when loading
|
|
635
|
+
an attack graph from a file
|
|
636
|
+
"""
|
|
637
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
638
|
+
# Avoid running json.dumps when not in debug
|
|
639
|
+
logger.debug(f'Add node \"{node.full_name}\" '
|
|
640
|
+
f'with id:{node_id}:\n' \
|
|
641
|
+
+ json.dumps(node.to_dict(), indent = 2))
|
|
642
|
+
|
|
643
|
+
if node.id in self._id_to_node:
|
|
644
|
+
raise ValueError(f'Node index {node_id} already in use.')
|
|
645
|
+
|
|
646
|
+
node.id = node_id if node_id is not None else self.next_node_id
|
|
647
|
+
self.next_node_id = max(node.id + 1, self.next_node_id)
|
|
648
|
+
|
|
649
|
+
self.nodes.append(node)
|
|
650
|
+
self._id_to_node[node.id] = node
|
|
651
|
+
self._full_name_to_node[node.full_name] = node
|
|
652
|
+
|
|
653
|
+
def remove_node(self, node: AttackGraphNode) -> None:
|
|
654
|
+
"""Remove node from attack graph
|
|
655
|
+
Arguments:
|
|
656
|
+
node - the node we wish to remove from the attack graph
|
|
657
|
+
"""
|
|
658
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
659
|
+
# Avoid running json.dumps when not in debug
|
|
660
|
+
logger.debug(f'Remove node "%s"(%d).', node.full_name, node.id)
|
|
661
|
+
for child in node.children:
|
|
662
|
+
child.parents.remove(node)
|
|
663
|
+
for parent in node.parents:
|
|
664
|
+
parent.children.remove(node)
|
|
665
|
+
self.nodes.remove(node)
|
|
666
|
+
|
|
667
|
+
if not isinstance(node.id, int):
|
|
668
|
+
raise ValueError(f'Invalid node id.')
|
|
669
|
+
del self._id_to_node[node.id]
|
|
670
|
+
del self._full_name_to_node[node.full_name]
|
|
671
|
+
|
|
672
|
+
def add_attacker(
|
|
673
|
+
self,
|
|
674
|
+
attacker: Attacker,
|
|
675
|
+
attacker_id: Optional[int] = None,
|
|
676
|
+
entry_points: list[int] = [],
|
|
677
|
+
reached_attack_steps: list[int] = []
|
|
678
|
+
):
|
|
679
|
+
"""Add an attacker to the graph
|
|
680
|
+
Arguments:
|
|
681
|
+
attacker - the attacker to add
|
|
682
|
+
attacker_id - the id to assign to this attacker, usually
|
|
683
|
+
used when loading an attack graph from a
|
|
684
|
+
file
|
|
685
|
+
entry_points - list of attack step ids that serve as entry
|
|
686
|
+
points for the attacker
|
|
687
|
+
reached_attack_steps - list of ids of the attack steps that the
|
|
688
|
+
attacker has reached
|
|
689
|
+
"""
|
|
690
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
691
|
+
# Avoid running json.dumps when not in debug
|
|
692
|
+
if attacker_id is not None:
|
|
693
|
+
logger.debug('Add attacker "%s" with id:%d.',
|
|
694
|
+
attacker.name,
|
|
695
|
+
attacker_id)
|
|
696
|
+
else:
|
|
697
|
+
logger.debug('Add attacker "%s" without id.',
|
|
698
|
+
attacker.name)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
attacker.id = attacker_id or self.next_attacker_id
|
|
702
|
+
if attacker.id in self._id_to_attacker:
|
|
703
|
+
raise ValueError(f'Attacker index {attacker_id} already in use.')
|
|
704
|
+
|
|
705
|
+
self.next_attacker_id = max(attacker.id + 1, self.next_attacker_id)
|
|
706
|
+
for node_id in reached_attack_steps:
|
|
707
|
+
node = self.get_node_by_id(node_id)
|
|
708
|
+
if node:
|
|
709
|
+
attacker.compromise(node)
|
|
710
|
+
else:
|
|
711
|
+
msg = ("Could not find node with id %d"
|
|
712
|
+
"in reached attack steps.")
|
|
713
|
+
logger.error(msg, node_id)
|
|
714
|
+
raise AttackGraphException(msg % node_id)
|
|
715
|
+
for node_id in entry_points:
|
|
716
|
+
node = self.get_node_by_id(int(node_id))
|
|
717
|
+
if node:
|
|
718
|
+
attacker.entry_points.append(node)
|
|
719
|
+
else:
|
|
720
|
+
msg = ("Could not find node with id %d"
|
|
721
|
+
"in attacker entrypoints.")
|
|
722
|
+
logger.error(msg, node_id)
|
|
723
|
+
raise AttackGraphException(msg % node_id)
|
|
724
|
+
self.attackers.append(attacker)
|
|
725
|
+
self._id_to_attacker[attacker.id] = attacker
|
|
726
|
+
|
|
727
|
+
def remove_attacker(self, attacker: Attacker):
|
|
728
|
+
"""Remove attacker from attack graph
|
|
729
|
+
Arguments:
|
|
730
|
+
attacker - the attacker we wish to remove from the attack graph
|
|
731
|
+
"""
|
|
732
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
733
|
+
# Avoid running json.dumps when not in debug
|
|
734
|
+
logger.debug('Remove attacker "%s" with id:%d.',
|
|
735
|
+
attacker.name,
|
|
736
|
+
attacker.id)
|
|
737
|
+
for node in attacker.reached_attack_steps:
|
|
738
|
+
attacker.undo_compromise(node)
|
|
739
|
+
self.attackers.remove(attacker)
|
|
740
|
+
if not isinstance(attacker.id, int):
|
|
741
|
+
raise ValueError(f'Invalid attacker id.')
|
|
742
|
+
del self._id_to_attacker[attacker.id]
|