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
maltoolbox/model.py
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MAL-Toolbox Model Module
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from .file_utils import (
|
|
12
|
+
load_dict_from_json_file,
|
|
13
|
+
load_dict_from_yaml_file,
|
|
14
|
+
save_dict_to_file
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .exceptions import DuplicateModelAssociationError, ModelAssociationException
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from typing import Any, Optional, TypeAlias
|
|
22
|
+
from .language import LanguageClassesFactory
|
|
23
|
+
from python_jsonschema_objects.classbuilder import ProtocolBase
|
|
24
|
+
|
|
25
|
+
SchemaGeneratedClass: TypeAlias = ProtocolBase
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AttackerAttachment:
|
|
31
|
+
"""Used to attach attackers to attack step entry points of assets"""
|
|
32
|
+
id: Optional[int] = None
|
|
33
|
+
name: Optional[str] = None
|
|
34
|
+
entry_points: list[tuple[SchemaGeneratedClass, list[str]]] = \
|
|
35
|
+
field(default_factory=lambda: [])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_entry_point_tuple(
|
|
39
|
+
self,
|
|
40
|
+
asset: SchemaGeneratedClass
|
|
41
|
+
) -> Optional[tuple[SchemaGeneratedClass, list[str]]]:
|
|
42
|
+
"""Return an entry point tuple of an AttackerAttachment matching the
|
|
43
|
+
asset provided.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Arguments:
|
|
47
|
+
asset - the asset to add entry point to
|
|
48
|
+
|
|
49
|
+
Return:
|
|
50
|
+
The entry point tuple containing the asset and the list of attack
|
|
51
|
+
steps if the asset has any entry points defined for this attacker
|
|
52
|
+
attachemnt.
|
|
53
|
+
None, otherwise.
|
|
54
|
+
"""
|
|
55
|
+
return next((ep_tuple for ep_tuple in self.entry_points
|
|
56
|
+
if ep_tuple[0] == asset), None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def add_entry_point(
|
|
60
|
+
self, asset: SchemaGeneratedClass, attackstep_name: str):
|
|
61
|
+
"""Add an entry point to an AttackerAttachment
|
|
62
|
+
|
|
63
|
+
self.entry_points contain tuples, first element of each tuple
|
|
64
|
+
is an asset, second element is a list of attack step names that
|
|
65
|
+
are entry points for the attacker.
|
|
66
|
+
|
|
67
|
+
Arguments:
|
|
68
|
+
asset - the asset to add the entry point to
|
|
69
|
+
attackstep_name - the name of the attack step to add as an entry point
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
logger.debug(
|
|
73
|
+
f'Add entry point "{attackstep_name}" on asset "{asset.name}" '
|
|
74
|
+
f'to AttackerAttachment "{self.name}".'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Get the entry point tuple for the asset if it already exists
|
|
78
|
+
entry_point_tuple = self.get_entry_point_tuple(asset)
|
|
79
|
+
|
|
80
|
+
if entry_point_tuple:
|
|
81
|
+
if attackstep_name not in entry_point_tuple[1]:
|
|
82
|
+
# If it exists and does not already have the attack step,
|
|
83
|
+
# add it
|
|
84
|
+
entry_point_tuple[1].append(attackstep_name)
|
|
85
|
+
else:
|
|
86
|
+
logger.info(
|
|
87
|
+
f'Entry point "{attackstep_name}" on asset "{asset.name}"'
|
|
88
|
+
f' already existed for AttackerAttachment "{self.name}".'
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
# Otherwise, create the entry point tuple and the initial entry
|
|
92
|
+
# point
|
|
93
|
+
self.entry_points.append((asset, [attackstep_name]))
|
|
94
|
+
|
|
95
|
+
def remove_entry_point(
|
|
96
|
+
self, asset: SchemaGeneratedClass, attackstep_name: str):
|
|
97
|
+
"""Remove an entry point from an AttackerAttachment if it exists
|
|
98
|
+
|
|
99
|
+
Arguments:
|
|
100
|
+
asset - the asset to remove the entry point from
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
logger.debug(
|
|
104
|
+
f'Remove entry point "{attackstep_name}" on asset "{asset.name}" '
|
|
105
|
+
f'from AttackerAttachment "{self.name}".'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Get the entry point tuple for the asset if it exists
|
|
109
|
+
entry_point_tuple = self.get_entry_point_tuple(asset)
|
|
110
|
+
|
|
111
|
+
if entry_point_tuple:
|
|
112
|
+
if attackstep_name in entry_point_tuple[1]:
|
|
113
|
+
# If it exists and not already has the attack step, add it
|
|
114
|
+
entry_point_tuple[1].remove(attackstep_name)
|
|
115
|
+
else:
|
|
116
|
+
logger.warning(
|
|
117
|
+
f'Failed to find entry point "{attackstep_name}" on '
|
|
118
|
+
f'asset "{asset.name}" for AttackerAttachment '
|
|
119
|
+
f'"{self.name}". Nothing to remove.'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not entry_point_tuple[1]:
|
|
123
|
+
self.entry_points.remove(entry_point_tuple)
|
|
124
|
+
else:
|
|
125
|
+
logger.warning(
|
|
126
|
+
f'Failed to find entry points on asset "{asset.name}" '
|
|
127
|
+
f'for AttackerAttachment "{self.name}". Nothing to remove.'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Model():
|
|
132
|
+
"""An implementation of a MAL language with assets and associations"""
|
|
133
|
+
next_id: int = 0
|
|
134
|
+
|
|
135
|
+
def __repr__(self) -> str:
|
|
136
|
+
return f'Model {self.name}'
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
name: str,
|
|
141
|
+
lang_classes_factory: LanguageClassesFactory,
|
|
142
|
+
mt_version: str = __version__
|
|
143
|
+
):
|
|
144
|
+
|
|
145
|
+
self.name = name
|
|
146
|
+
self.assets: list[SchemaGeneratedClass] = []
|
|
147
|
+
self.associations: list[SchemaGeneratedClass] = []
|
|
148
|
+
self._type_to_association:dict = {} # optimization
|
|
149
|
+
self.attackers: list[AttackerAttachment] = []
|
|
150
|
+
self.lang_classes_factory: LanguageClassesFactory = lang_classes_factory
|
|
151
|
+
self.maltoolbox_version: str = mt_version
|
|
152
|
+
|
|
153
|
+
# Below sets used to check for duplicate names or ids,
|
|
154
|
+
# better for optimization than iterating over all assets
|
|
155
|
+
self.asset_ids: set[int] = set()
|
|
156
|
+
self.asset_names: set[str] = set()
|
|
157
|
+
|
|
158
|
+
def add_asset(
|
|
159
|
+
self,
|
|
160
|
+
asset: SchemaGeneratedClass,
|
|
161
|
+
asset_id: Optional[int] = None,
|
|
162
|
+
allow_duplicate_names: bool = True
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Add an asset to the model.
|
|
165
|
+
|
|
166
|
+
Arguments:
|
|
167
|
+
asset - the asset to add to the model
|
|
168
|
+
asset_id - the id to assign to this asset, usually
|
|
169
|
+
from an instance model file
|
|
170
|
+
allow_duplicate_name - allow duplicate names to be used. If allowed
|
|
171
|
+
and a duplicate is encountered the name will
|
|
172
|
+
be appended with the id.
|
|
173
|
+
|
|
174
|
+
Return:
|
|
175
|
+
An asset matching the name if it exists in the model.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
# Set asset ID and check for duplicates
|
|
179
|
+
asset.id = asset_id or self.next_id
|
|
180
|
+
if asset.id in self.asset_ids:
|
|
181
|
+
raise ValueError(f'Asset index {asset_id} already in use.')
|
|
182
|
+
self.asset_ids.add(asset.id)
|
|
183
|
+
|
|
184
|
+
self.next_id = max(asset.id + 1, self.next_id)
|
|
185
|
+
|
|
186
|
+
asset.associations = []
|
|
187
|
+
|
|
188
|
+
if not hasattr(asset, 'name'):
|
|
189
|
+
asset.name = asset.type + ':' + str(asset.id)
|
|
190
|
+
else:
|
|
191
|
+
if asset.name in self.asset_names:
|
|
192
|
+
if allow_duplicate_names:
|
|
193
|
+
asset.name = asset.name + ':' + str(asset.id)
|
|
194
|
+
else:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f'Asset name {asset.name} is a duplicate'
|
|
197
|
+
' and we do not allow duplicates.'
|
|
198
|
+
)
|
|
199
|
+
self.asset_names.add(asset.name)
|
|
200
|
+
|
|
201
|
+
# Optional field for extra asset data
|
|
202
|
+
if not hasattr(asset, 'extras'):
|
|
203
|
+
asset.extras = {}
|
|
204
|
+
|
|
205
|
+
logger.debug(
|
|
206
|
+
'Add "%s"(%d) to model "%s".', asset.name, asset.id, self.name
|
|
207
|
+
)
|
|
208
|
+
self.assets.append(asset)
|
|
209
|
+
|
|
210
|
+
def remove_attacker(self, attacker: AttackerAttachment) -> None:
|
|
211
|
+
"""Remove attacker"""
|
|
212
|
+
self.attackers.remove(attacker)
|
|
213
|
+
|
|
214
|
+
def remove_asset(self, asset: SchemaGeneratedClass) -> None:
|
|
215
|
+
"""Remove an asset from the model.
|
|
216
|
+
|
|
217
|
+
Arguments:
|
|
218
|
+
asset - the asset to remove
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
logger.debug(
|
|
222
|
+
'Remove "%s"(%d) from model "%s".',
|
|
223
|
+
asset.name, asset.id, self.name
|
|
224
|
+
)
|
|
225
|
+
if asset not in self.assets:
|
|
226
|
+
raise LookupError(
|
|
227
|
+
f'Asset "{asset.name}"({asset.id}) is not part'
|
|
228
|
+
f' of model"{self.name}".'
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# First remove all of the associations
|
|
232
|
+
for association in asset.associations:
|
|
233
|
+
self.remove_asset_from_association(asset, association)
|
|
234
|
+
|
|
235
|
+
# Also remove all of the entry points
|
|
236
|
+
for attacker in self.attackers:
|
|
237
|
+
entry_point_tuple = attacker.get_entry_point_tuple(asset)
|
|
238
|
+
if entry_point_tuple:
|
|
239
|
+
attacker.entry_points.remove(entry_point_tuple)
|
|
240
|
+
|
|
241
|
+
self.assets.remove(asset)
|
|
242
|
+
|
|
243
|
+
def remove_asset_from_association(
|
|
244
|
+
self,
|
|
245
|
+
asset: SchemaGeneratedClass,
|
|
246
|
+
association: SchemaGeneratedClass
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Remove an asset from an association and remove the association
|
|
249
|
+
if any of the two sides is now empty.
|
|
250
|
+
|
|
251
|
+
Arguments:
|
|
252
|
+
asset - the asset to remove from the given association
|
|
253
|
+
association - the association to remove the asset from
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
logger.debug(
|
|
257
|
+
'Remove "%s"(%d) from association of type "%s".',
|
|
258
|
+
asset.name, asset.id, type(association)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if asset not in self.assets:
|
|
262
|
+
raise LookupError(
|
|
263
|
+
f'Asset "{asset.name}"({asset.id}) is not part of model '
|
|
264
|
+
f'"{self.name}".'
|
|
265
|
+
)
|
|
266
|
+
if association not in self.associations:
|
|
267
|
+
raise LookupError(
|
|
268
|
+
f'Association is not part of model "{self.name}".'
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
left_field_name, right_field_name = \
|
|
272
|
+
self.get_association_field_names(association)
|
|
273
|
+
left_field = getattr(association, left_field_name)
|
|
274
|
+
right_field = getattr(association, right_field_name)
|
|
275
|
+
found = False
|
|
276
|
+
for field in [left_field, right_field]:
|
|
277
|
+
if asset in field:
|
|
278
|
+
found = True
|
|
279
|
+
if len(field) == 1:
|
|
280
|
+
# There are no other assets on this side,
|
|
281
|
+
# so we should remove the entire association.
|
|
282
|
+
self.remove_association(association)
|
|
283
|
+
return
|
|
284
|
+
field.remove(asset)
|
|
285
|
+
|
|
286
|
+
if not found:
|
|
287
|
+
raise LookupError(f'Asset "{asset.name}"({asset.id}) is not '
|
|
288
|
+
'part of the association provided.')
|
|
289
|
+
|
|
290
|
+
def _validate_association(self, association: SchemaGeneratedClass) -> None:
|
|
291
|
+
"""Raise error if association is invalid or already part of the Model.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
DuplicateAssociationError - same association already exists
|
|
295
|
+
ModelAssociationException - association is not valid
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
# Optimization: only look for duplicates in associations of same type
|
|
299
|
+
association_type = association.__class__.__name__
|
|
300
|
+
associations_same_type = self._type_to_association.get(
|
|
301
|
+
association_type, []
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Check if identical association already exists
|
|
305
|
+
if association in associations_same_type:
|
|
306
|
+
raise DuplicateModelAssociationError(
|
|
307
|
+
f"Identical association {association_type} already exists"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# Check for duplicate assets in each field
|
|
312
|
+
left_field_name, right_field_name = \
|
|
313
|
+
self.get_association_field_names(association)
|
|
314
|
+
|
|
315
|
+
for field_name in (left_field_name, right_field_name):
|
|
316
|
+
field_assets = getattr(association, field_name)
|
|
317
|
+
|
|
318
|
+
unique_field_asset_names = {a.name for a in field_assets}
|
|
319
|
+
if len(field_assets) > len(unique_field_asset_names):
|
|
320
|
+
raise ModelAssociationException(
|
|
321
|
+
"More than one asset share same name in field"
|
|
322
|
+
f"{association_type}.{field_name}"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# For each asset in left field, go through each assets in right field
|
|
326
|
+
# to find all unique connections. Raise error if a connection between
|
|
327
|
+
# two assets already exist in a previously added association.
|
|
328
|
+
for left_asset in getattr(association, left_field_name):
|
|
329
|
+
for right_asset in getattr(association, right_field_name):
|
|
330
|
+
|
|
331
|
+
if self.association_exists_between_assets(
|
|
332
|
+
association_type, left_asset, right_asset
|
|
333
|
+
):
|
|
334
|
+
# Assets already have the connection in another
|
|
335
|
+
# association with same type
|
|
336
|
+
raise DuplicateModelAssociationError(
|
|
337
|
+
f"Association type {association_type} already exists"
|
|
338
|
+
f" between {left_asset.name} and {right_asset.name}"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def add_association(self, association: SchemaGeneratedClass) -> None:
|
|
342
|
+
"""Add an association to the model.
|
|
343
|
+
|
|
344
|
+
An association will have 2 field names, each
|
|
345
|
+
potentially containing several assets.
|
|
346
|
+
|
|
347
|
+
Arguments:
|
|
348
|
+
association - the association to add to the model
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
DuplicateAssociationError - same association already exists
|
|
352
|
+
ModelAssociationException - association is not valid
|
|
353
|
+
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
# Check association is valid and not duplicate
|
|
357
|
+
self._validate_association(association)
|
|
358
|
+
|
|
359
|
+
# Optional field for extra association data
|
|
360
|
+
association.extras = {}
|
|
361
|
+
|
|
362
|
+
field_names = self.get_association_field_names(association)
|
|
363
|
+
|
|
364
|
+
# Add the association to all of the included assets
|
|
365
|
+
for field_name in field_names:
|
|
366
|
+
for asset in getattr(association, field_name):
|
|
367
|
+
asset_assocs = list(asset.associations)
|
|
368
|
+
asset_assocs.append(association)
|
|
369
|
+
asset.associations = asset_assocs
|
|
370
|
+
|
|
371
|
+
self.associations.append(association)
|
|
372
|
+
|
|
373
|
+
# Add association to type->association mapping
|
|
374
|
+
association_type = association.__class__.__name__
|
|
375
|
+
self._type_to_association.setdefault(
|
|
376
|
+
association_type, []
|
|
377
|
+
).append(association)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def remove_association(self, association: SchemaGeneratedClass) -> None:
|
|
381
|
+
"""Remove an association from the model.
|
|
382
|
+
|
|
383
|
+
Arguments:
|
|
384
|
+
association - the association to remove from the model
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
if association not in self.associations:
|
|
388
|
+
raise LookupError(
|
|
389
|
+
f'Association is not part of model "{self.name}".'
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
left_field_name, right_field_name = \
|
|
393
|
+
self.get_association_field_names(association)
|
|
394
|
+
left_field = getattr(association, left_field_name)
|
|
395
|
+
right_field = getattr(association, right_field_name)
|
|
396
|
+
|
|
397
|
+
for asset in left_field:
|
|
398
|
+
assocs = list(asset.associations)
|
|
399
|
+
assocs.remove(association)
|
|
400
|
+
asset.associations = assocs
|
|
401
|
+
|
|
402
|
+
for asset in right_field:
|
|
403
|
+
# In fringe cases we may have reflexive associations where the
|
|
404
|
+
# association was already removed when processing the left field
|
|
405
|
+
# assets therefore we have to check if it is still in the list.
|
|
406
|
+
if association in asset.associations:
|
|
407
|
+
assocs = list(asset.associations)
|
|
408
|
+
assocs.remove(association)
|
|
409
|
+
asset.associations = assocs
|
|
410
|
+
|
|
411
|
+
self.associations.remove(association)
|
|
412
|
+
|
|
413
|
+
# Remove association from type->association mapping
|
|
414
|
+
association_type = association.__class__.__name__
|
|
415
|
+
self._type_to_association[association_type].remove(
|
|
416
|
+
association
|
|
417
|
+
)
|
|
418
|
+
# Remove type from type->association mapping if mapping empty
|
|
419
|
+
if len(self._type_to_association[association_type]) == 0:
|
|
420
|
+
del self._type_to_association[association_type]
|
|
421
|
+
|
|
422
|
+
def add_attacker(
|
|
423
|
+
self,
|
|
424
|
+
attacker: AttackerAttachment,
|
|
425
|
+
attacker_id: Optional[int] = None
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Add an attacker to the model.
|
|
428
|
+
|
|
429
|
+
Arguments:
|
|
430
|
+
attacker - the attacker to add
|
|
431
|
+
attacker_id - optional id for the attacker
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
if attacker_id is not None:
|
|
435
|
+
attacker.id = attacker_id
|
|
436
|
+
else:
|
|
437
|
+
attacker.id = self.next_id
|
|
438
|
+
self.next_id = max(attacker.id + 1, self.next_id)
|
|
439
|
+
|
|
440
|
+
if not hasattr(attacker, 'name') or not attacker.name:
|
|
441
|
+
attacker.name = 'Attacker:' + str(attacker.id)
|
|
442
|
+
self.attackers.append(attacker)
|
|
443
|
+
|
|
444
|
+
def get_asset_by_id(
|
|
445
|
+
self, asset_id: int
|
|
446
|
+
) -> Optional[SchemaGeneratedClass]:
|
|
447
|
+
"""
|
|
448
|
+
Find an asset in the model based on its id.
|
|
449
|
+
|
|
450
|
+
Arguments:
|
|
451
|
+
asset_id - the id of the asset we are looking for
|
|
452
|
+
|
|
453
|
+
Return:
|
|
454
|
+
An asset matching the id if it exists in the model.
|
|
455
|
+
"""
|
|
456
|
+
logger.debug(
|
|
457
|
+
'Get asset with id %d from model "%s".',
|
|
458
|
+
asset_id, self.name
|
|
459
|
+
)
|
|
460
|
+
return next(
|
|
461
|
+
(asset for asset in self.assets
|
|
462
|
+
if asset.id == asset_id), None
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def get_asset_by_name(
|
|
466
|
+
self, asset_name: str
|
|
467
|
+
) -> Optional[SchemaGeneratedClass]:
|
|
468
|
+
"""
|
|
469
|
+
Find an asset in the model based on its name.
|
|
470
|
+
|
|
471
|
+
Arguments:
|
|
472
|
+
asset_name - the name of the asset we are looking for
|
|
473
|
+
|
|
474
|
+
Return:
|
|
475
|
+
An asset matching the name if it exists in the model.
|
|
476
|
+
"""
|
|
477
|
+
logger.debug(
|
|
478
|
+
'Get asset with name "%s" from model "%s".',
|
|
479
|
+
asset_name, self.name
|
|
480
|
+
)
|
|
481
|
+
return next(
|
|
482
|
+
(asset for asset in self.assets
|
|
483
|
+
if asset.name == asset_name), None
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def get_attacker_by_id(
|
|
487
|
+
self, attacker_id: int
|
|
488
|
+
) -> Optional[AttackerAttachment]:
|
|
489
|
+
"""
|
|
490
|
+
Find an attacker in the model based on its id.
|
|
491
|
+
|
|
492
|
+
Arguments:
|
|
493
|
+
attacker_id - the id of the attacker we are looking for
|
|
494
|
+
|
|
495
|
+
Return:
|
|
496
|
+
An attacker matching the id if it exists in the model.
|
|
497
|
+
"""
|
|
498
|
+
logger.debug(
|
|
499
|
+
'Get attacker with id %d from model "%s".',
|
|
500
|
+
attacker_id, self.name
|
|
501
|
+
)
|
|
502
|
+
return next(
|
|
503
|
+
(attacker for attacker in self.attackers
|
|
504
|
+
if attacker.id == attacker_id), None
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def association_exists_between_assets(
|
|
508
|
+
self,
|
|
509
|
+
association_type: str,
|
|
510
|
+
left_asset: SchemaGeneratedClass,
|
|
511
|
+
right_asset: SchemaGeneratedClass
|
|
512
|
+
):
|
|
513
|
+
"""Return True if the association already exists between the assets"""
|
|
514
|
+
logger.debug(
|
|
515
|
+
'Check to see if an association of type "%s" '
|
|
516
|
+
'already exists between "%s" and "%s".',
|
|
517
|
+
association_type, left_asset.name, right_asset.name
|
|
518
|
+
)
|
|
519
|
+
associations = self._type_to_association.get(association_type, [])
|
|
520
|
+
for association in associations:
|
|
521
|
+
left_field_name, right_field_name = \
|
|
522
|
+
self.get_association_field_names(association)
|
|
523
|
+
if (left_asset.id in [asset.id for asset in \
|
|
524
|
+
getattr(association, left_field_name)] and \
|
|
525
|
+
right_asset.id in [asset.id for asset in \
|
|
526
|
+
getattr(association, right_field_name)]):
|
|
527
|
+
logger.debug(
|
|
528
|
+
'An association of type "%s" '
|
|
529
|
+
'already exists between "%s" and "%s".',
|
|
530
|
+
association_type, left_asset.name, right_asset.name
|
|
531
|
+
)
|
|
532
|
+
return True
|
|
533
|
+
logger.debug(
|
|
534
|
+
'No association of type "%s" '
|
|
535
|
+
'exists between "%s" and "%s".',
|
|
536
|
+
association_type, left_asset.name, right_asset.name
|
|
537
|
+
)
|
|
538
|
+
return False
|
|
539
|
+
|
|
540
|
+
def get_asset_defenses(
|
|
541
|
+
self,
|
|
542
|
+
asset: SchemaGeneratedClass,
|
|
543
|
+
include_defaults: bool = False
|
|
544
|
+
):
|
|
545
|
+
"""
|
|
546
|
+
Get the two field names of the association as a list.
|
|
547
|
+
Arguments:
|
|
548
|
+
asset - the asset to fetch the defenses for
|
|
549
|
+
include_defaults - if not True the defenses that have default
|
|
550
|
+
values will not be included in the list
|
|
551
|
+
|
|
552
|
+
Return:
|
|
553
|
+
A dictionary containing the defenses of the asset
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
defenses = {}
|
|
557
|
+
for key, value in asset._properties.items():
|
|
558
|
+
property_schema = (
|
|
559
|
+
self.lang_classes_factory.json_schema['definitions']['LanguageAsset']
|
|
560
|
+
['definitions'][asset.type]['properties'][key]
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if "maximum" not in property_schema:
|
|
564
|
+
# Check if property is a defense by looking up defense
|
|
565
|
+
# specific key. Skip if it is not a defense.
|
|
566
|
+
continue
|
|
567
|
+
|
|
568
|
+
logger.debug(
|
|
569
|
+
'Translating %s: %s defense to dictionary.',
|
|
570
|
+
key,
|
|
571
|
+
value
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
if not include_defaults and value == value.default():
|
|
575
|
+
# Skip the defense values if they are the default ones.
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
defenses[key] = float(value)
|
|
579
|
+
|
|
580
|
+
return defenses
|
|
581
|
+
|
|
582
|
+
def get_association_field_names(
|
|
583
|
+
self,
|
|
584
|
+
association: SchemaGeneratedClass
|
|
585
|
+
):
|
|
586
|
+
"""
|
|
587
|
+
Get the two field names of the association as a list.
|
|
588
|
+
Arguments:
|
|
589
|
+
association - the association to fetch the field names for
|
|
590
|
+
|
|
591
|
+
Return:
|
|
592
|
+
A two item list containing the field names of the association.
|
|
593
|
+
"""
|
|
594
|
+
|
|
595
|
+
return association._properties.keys()
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def get_associated_assets_by_field_name(
|
|
599
|
+
self,
|
|
600
|
+
asset: SchemaGeneratedClass,
|
|
601
|
+
field_name: str
|
|
602
|
+
) -> list[SchemaGeneratedClass]:
|
|
603
|
+
"""
|
|
604
|
+
Get a list of associated assets for an asset given a field name.
|
|
605
|
+
|
|
606
|
+
Arguments:
|
|
607
|
+
asset - the asset whose fields we are interested in
|
|
608
|
+
field_name - the field name we are looking for
|
|
609
|
+
|
|
610
|
+
Return:
|
|
611
|
+
A list of assets associated with the asset given that match the
|
|
612
|
+
field_name.
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
logger.debug(
|
|
616
|
+
'Get associated assets for asset "%s"(%d) by field name %s.',
|
|
617
|
+
asset.name, asset.id, field_name
|
|
618
|
+
)
|
|
619
|
+
associated_assets = []
|
|
620
|
+
for association in asset.associations:
|
|
621
|
+
# Determine which two of the fields matches the asset given.
|
|
622
|
+
# The other field will provide the associated assets.
|
|
623
|
+
left_field_name, right_field_name = \
|
|
624
|
+
self.get_association_field_names(association)
|
|
625
|
+
|
|
626
|
+
if asset in getattr(association, left_field_name):
|
|
627
|
+
opposite_field_name = right_field_name
|
|
628
|
+
else:
|
|
629
|
+
opposite_field_name = left_field_name
|
|
630
|
+
|
|
631
|
+
if opposite_field_name == field_name:
|
|
632
|
+
associated_assets.extend(
|
|
633
|
+
getattr(association, opposite_field_name)
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
return associated_assets
|
|
637
|
+
|
|
638
|
+
def asset_to_dict(self, asset: SchemaGeneratedClass) -> tuple[str, dict]:
|
|
639
|
+
"""Get dictionary representation of the asset.
|
|
640
|
+
|
|
641
|
+
Arguments:
|
|
642
|
+
asset - asset to get dictionary representation of
|
|
643
|
+
|
|
644
|
+
Return: tuple with name of asset and the asset as dict
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
logger.debug(
|
|
648
|
+
'Translating "%s"(%d) to dictionary.',
|
|
649
|
+
asset.name,
|
|
650
|
+
asset.id
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
asset_dict: dict[str, Any] = {
|
|
655
|
+
'name': str(asset.name),
|
|
656
|
+
'type': str(asset.type)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
defenses = self.get_asset_defenses(asset)
|
|
660
|
+
|
|
661
|
+
if defenses:
|
|
662
|
+
asset_dict['defenses'] = defenses
|
|
663
|
+
|
|
664
|
+
if asset.extras:
|
|
665
|
+
# Add optional metadata to dict
|
|
666
|
+
asset_dict['extras'] = asset.extras.as_dict()
|
|
667
|
+
|
|
668
|
+
return (asset.id, asset_dict)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def association_to_dict(self, association: SchemaGeneratedClass) -> dict:
|
|
672
|
+
"""Get dictionary representation of the association.
|
|
673
|
+
|
|
674
|
+
Arguments:
|
|
675
|
+
association - association to get dictionary representation of
|
|
676
|
+
|
|
677
|
+
Returns the association serialized to a dict
|
|
678
|
+
"""
|
|
679
|
+
|
|
680
|
+
left_field_name, right_field_name = \
|
|
681
|
+
self.get_association_field_names(association)
|
|
682
|
+
left_field = getattr(association, left_field_name)
|
|
683
|
+
right_field = getattr(association, right_field_name)
|
|
684
|
+
|
|
685
|
+
association_dict = {
|
|
686
|
+
association.__class__.__name__ :
|
|
687
|
+
{
|
|
688
|
+
str(left_field_name):
|
|
689
|
+
[int(asset.id) for asset in left_field],
|
|
690
|
+
str(right_field_name):
|
|
691
|
+
[int(asset.id) for asset in right_field]
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if association.extras:
|
|
696
|
+
# Add optional metadata to dict
|
|
697
|
+
association_dict['extras'] = association.extras
|
|
698
|
+
|
|
699
|
+
return association_dict
|
|
700
|
+
|
|
701
|
+
def attacker_to_dict(
|
|
702
|
+
self, attacker: AttackerAttachment
|
|
703
|
+
) -> tuple[Optional[int], dict]:
|
|
704
|
+
"""Get dictionary representation of the attacker.
|
|
705
|
+
|
|
706
|
+
Arguments:
|
|
707
|
+
attacker - attacker to get dictionary representation of
|
|
708
|
+
"""
|
|
709
|
+
|
|
710
|
+
logger.debug('Translating %s to dictionary.', attacker.name)
|
|
711
|
+
attacker_dict: dict[str, Any] = {
|
|
712
|
+
'name': str(attacker.name),
|
|
713
|
+
'entry_points': {},
|
|
714
|
+
}
|
|
715
|
+
for (asset, attack_steps) in attacker.entry_points:
|
|
716
|
+
attacker_dict['entry_points'][int(asset.id)] = {
|
|
717
|
+
'attack_steps' : attack_steps
|
|
718
|
+
}
|
|
719
|
+
return (attacker.id, attacker_dict)
|
|
720
|
+
|
|
721
|
+
def _to_dict(self) -> dict:
|
|
722
|
+
"""Get dictionary representation of the model."""
|
|
723
|
+
logger.debug('Translating model to dict.')
|
|
724
|
+
contents: dict[str, Any] = {
|
|
725
|
+
'metadata': {},
|
|
726
|
+
'assets': {},
|
|
727
|
+
'associations': [],
|
|
728
|
+
'attackers' : {}
|
|
729
|
+
}
|
|
730
|
+
contents['metadata'] = {
|
|
731
|
+
'name': self.name,
|
|
732
|
+
'langVersion': self.lang_classes_factory.lang_graph.metadata['version'],
|
|
733
|
+
'langID': self.lang_classes_factory.lang_graph.metadata['id'],
|
|
734
|
+
'malVersion': '0.1.0-SNAPSHOT',
|
|
735
|
+
'MAL-Toolbox Version': __version__,
|
|
736
|
+
'info': 'Created by the mal-toolbox model python module.'
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
logger.debug('Translating assets to dictionary.')
|
|
740
|
+
for asset in self.assets:
|
|
741
|
+
(asset_id, asset_dict) = self.asset_to_dict(asset)
|
|
742
|
+
contents['assets'][int(asset_id)] = asset_dict
|
|
743
|
+
|
|
744
|
+
logger.debug('Translating associations to dictionary.')
|
|
745
|
+
for association in self.associations:
|
|
746
|
+
assoc_dict = self.association_to_dict(association)
|
|
747
|
+
contents['associations'].append(assoc_dict)
|
|
748
|
+
|
|
749
|
+
logger.debug('Translating attackers to dictionary.')
|
|
750
|
+
for attacker in self.attackers:
|
|
751
|
+
(attacker_id, attacker_dict) = self.attacker_to_dict(attacker)
|
|
752
|
+
contents['attackers'][attacker_id] = attacker_dict
|
|
753
|
+
return contents
|
|
754
|
+
|
|
755
|
+
def save_to_file(self, filename: str) -> None:
|
|
756
|
+
"""Save to json/yml depending on extension"""
|
|
757
|
+
logger.debug('Save instance model to file "%s".', filename)
|
|
758
|
+
return save_dict_to_file(filename, self._to_dict())
|
|
759
|
+
|
|
760
|
+
@classmethod
|
|
761
|
+
def _from_dict(
|
|
762
|
+
cls,
|
|
763
|
+
serialized_object: dict,
|
|
764
|
+
lang_classes_factory: LanguageClassesFactory
|
|
765
|
+
) -> Model:
|
|
766
|
+
"""Create a model from dict representation
|
|
767
|
+
|
|
768
|
+
Arguments:
|
|
769
|
+
serialized_object - Model in dict format
|
|
770
|
+
lang_classes_factory -
|
|
771
|
+
"""
|
|
772
|
+
|
|
773
|
+
maltoolbox_version = serialized_object['metadata']['MAL Toolbox Version'] \
|
|
774
|
+
if 'MAL Toolbox Version' in serialized_object['metadata'] \
|
|
775
|
+
else __version__
|
|
776
|
+
model = Model(
|
|
777
|
+
serialized_object['metadata']['name'],
|
|
778
|
+
lang_classes_factory,
|
|
779
|
+
mt_version = maltoolbox_version)
|
|
780
|
+
|
|
781
|
+
# Reconstruct the assets
|
|
782
|
+
for asset_id, asset_object in serialized_object['assets'].items():
|
|
783
|
+
|
|
784
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
785
|
+
# Avoid running json.dumps when not in debug
|
|
786
|
+
logger.debug(
|
|
787
|
+
"Loading asset:\n%s", json.dumps(asset_object, indent=2)
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
# Allow defining an asset via type only.
|
|
791
|
+
asset_object = (
|
|
792
|
+
asset_object
|
|
793
|
+
if isinstance(asset_object, dict)
|
|
794
|
+
else {
|
|
795
|
+
'type': asset_object,
|
|
796
|
+
'name': f"{asset_object}:{asset_id}"
|
|
797
|
+
}
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
asset = getattr(model.lang_classes_factory.ns,
|
|
801
|
+
asset_object['type'])(name = asset_object['name'])
|
|
802
|
+
|
|
803
|
+
if 'extras' in asset_object:
|
|
804
|
+
asset.extras = asset_object['extras']
|
|
805
|
+
|
|
806
|
+
for defense in (defenses:=asset_object.get('defenses', [])):
|
|
807
|
+
setattr(asset, defense, float(defenses[defense]))
|
|
808
|
+
|
|
809
|
+
model.add_asset(asset, asset_id = int(asset_id))
|
|
810
|
+
|
|
811
|
+
# Reconstruct the associations
|
|
812
|
+
for assoc_entry in serialized_object.get('associations', []):
|
|
813
|
+
assoc = list(assoc_entry.keys())[0]
|
|
814
|
+
assoc_fields = assoc_entry[assoc]
|
|
815
|
+
association = getattr(model.lang_classes_factory.ns, assoc)()
|
|
816
|
+
|
|
817
|
+
for field, targets in assoc_fields.items():
|
|
818
|
+
targets = targets if isinstance(targets, list) else [targets]
|
|
819
|
+
setattr(
|
|
820
|
+
association,
|
|
821
|
+
field,
|
|
822
|
+
[model.get_asset_by_id(int(id)) for id in targets]
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
#TODO Properly handle extras
|
|
826
|
+
|
|
827
|
+
model.add_association(association)
|
|
828
|
+
|
|
829
|
+
# Reconstruct the attackers
|
|
830
|
+
if 'attackers' in serialized_object:
|
|
831
|
+
attackers_info = serialized_object['attackers']
|
|
832
|
+
for attacker_id in attackers_info:
|
|
833
|
+
attacker = AttackerAttachment(name = attackers_info[attacker_id]['name'])
|
|
834
|
+
attacker.entry_points = []
|
|
835
|
+
for asset_id in attackers_info[attacker_id]['entry_points']:
|
|
836
|
+
attacker.entry_points.append(
|
|
837
|
+
(model.get_asset_by_id(int(asset_id)),
|
|
838
|
+
attackers_info[attacker_id]['entry_points']\
|
|
839
|
+
[asset_id]['attack_steps']))
|
|
840
|
+
model.add_attacker(attacker, attacker_id = int(attacker_id))
|
|
841
|
+
return model
|
|
842
|
+
|
|
843
|
+
@classmethod
|
|
844
|
+
def load_from_file(
|
|
845
|
+
cls,
|
|
846
|
+
filename: str,
|
|
847
|
+
lang_classes_factory: LanguageClassesFactory
|
|
848
|
+
) -> Model:
|
|
849
|
+
"""Create from json or yaml file depending on file extension"""
|
|
850
|
+
logger.debug('Load instance model from file "%s".', filename)
|
|
851
|
+
serialized_model = None
|
|
852
|
+
if filename.endswith(('.yml', '.yaml')):
|
|
853
|
+
serialized_model = load_dict_from_yaml_file(filename)
|
|
854
|
+
elif filename.endswith('.json'):
|
|
855
|
+
serialized_model = load_dict_from_json_file(filename)
|
|
856
|
+
else:
|
|
857
|
+
raise ValueError('Unknown file extension, expected json/yml/yaml')
|
|
858
|
+
return cls._from_dict(serialized_model, lang_classes_factory)
|