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.
Files changed (37) hide show
  1. {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/METADATA +60 -28
  2. mal_toolbox-0.1.12.dist-info/RECORD +32 -0
  3. {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/WHEEL +1 -1
  4. maltoolbox/__init__.py +31 -31
  5. maltoolbox/__main__.py +80 -4
  6. maltoolbox/attackgraph/__init__.py +8 -0
  7. maltoolbox/attackgraph/analyzers/__init__.py +0 -0
  8. maltoolbox/attackgraph/analyzers/apriori.py +173 -27
  9. maltoolbox/attackgraph/attacker.py +99 -21
  10. maltoolbox/attackgraph/attackgraph.py +507 -217
  11. maltoolbox/attackgraph/node.py +143 -21
  12. maltoolbox/attackgraph/query.py +128 -26
  13. maltoolbox/default.conf +8 -7
  14. maltoolbox/exceptions.py +45 -0
  15. maltoolbox/file_utils.py +66 -0
  16. maltoolbox/ingestors/__init__.py +0 -0
  17. maltoolbox/ingestors/neo4j.py +95 -84
  18. maltoolbox/language/__init__.py +4 -0
  19. maltoolbox/language/classes_factory.py +145 -64
  20. maltoolbox/language/{lexer_parser/__main__.py → compiler/__init__.py} +5 -12
  21. maltoolbox/language/{lexer_parser → compiler}/mal_lexer.py +1 -1
  22. maltoolbox/language/{lexer_parser → compiler}/mal_parser.py +1 -1
  23. maltoolbox/language/{lexer_parser → compiler}/mal_visitor.py +4 -5
  24. maltoolbox/language/languagegraph.py +569 -168
  25. maltoolbox/model.py +858 -0
  26. maltoolbox/translators/__init__.py +0 -0
  27. maltoolbox/translators/securicad.py +76 -52
  28. maltoolbox/translators/updater.py +132 -0
  29. maltoolbox/wrappers.py +62 -0
  30. mal_toolbox-0.0.27.dist-info/RECORD +0 -26
  31. maltoolbox/cl_parser.py +0 -89
  32. maltoolbox/language/specification.py +0 -265
  33. maltoolbox/main.py +0 -84
  34. maltoolbox/model/model.py +0 -279
  35. {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/AUTHORS +0 -0
  36. {mal_toolbox-0.0.27.dist-info → mal_toolbox-0.1.12.dist-info}/LICENSE +0 -0
  37. {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)