mal-toolbox 1.1.2__py3-none-any.whl → 1.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mal-toolbox
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: A collection of tools used to create MAL models and attack graphs.
5
5
  Author-email: Andrei Buhaiu <buhaiu@kth.se>, Joakim Loxdal <loxdal@kth.se>, Nikolaos Kakouros <nkak@kth.se>, Jakob Nyberg <jaknyb@kth.se>, Giuseppe Nebbione <nebbione@kth.se>, Sandor Berglund <sandor@kth.se>
6
6
  License: Apache Software License
@@ -75,6 +75,15 @@ available.
75
75
  pip install mal-toolbox
76
76
  ```
77
77
 
78
+ ### Requirements
79
+
80
+ If you wish to run visualisations with graphviz, you must first download and install it on your computer. Depending on your operating system, you can find out how to do this here: [link to graphviz installation](https://graphviz.org/download/).
81
+
82
+ Once the software has been successfully installed, you must also include the python package by running:
83
+ ```
84
+ pip install graphviz
85
+ ```
86
+
78
87
  ## Configuration
79
88
  You can use a `maltoolbox.yml` file in the current working directory to
80
89
  configure the toolbox.
@@ -1,17 +1,18 @@
1
- mal_toolbox-1.1.2.dist-info/licenses/AUTHORS,sha256=zxLrLe8EY39WtRKlAY4Oorx4Z2_LHV2ApRvDGZgY7xY,127
2
- mal_toolbox-1.1.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
3
- maltoolbox/__init__.py,sha256=x6canxmVl7H1GBftEkhQ3Hu3bl4YpYsLoYXztw7hWWU,2132
1
+ mal_toolbox-1.2.0.dist-info/licenses/AUTHORS,sha256=zxLrLe8EY39WtRKlAY4Oorx4Z2_LHV2ApRvDGZgY7xY,127
2
+ mal_toolbox-1.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
3
+ maltoolbox/__init__.py,sha256=477nzX87CW6thDBJ4BfSLW2xzvpqIHKL0llR7bJxXds,2132
4
4
  maltoolbox/__main__.py,sha256=aAm6NcZ-HtPmY9hfFlGNnTs5rydoI6NAc88RgXt1G9U,3515
5
5
  maltoolbox/exceptions.py,sha256=4rwqzu8Cgj0ShjUoCXP2yik-bJaqYqj6Y-0tqxHy4vs,1316
6
6
  maltoolbox/file_utils.py,sha256=IXA0cvyopjRFGGKqRPkRQ0RJOtKzq_XF13aHgcz-TFc,1911
7
- maltoolbox/model.py,sha256=dLKDb3CsGP0rBSIN0yc99-GOoD_I60cLmyup5yKCt3k,18989
7
+ maltoolbox/model.py,sha256=bqYPYNW8MxuS2wUef51z6yjaA5F13YXbeicM2qaYUd4,18138
8
8
  maltoolbox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ maltoolbox/str_utils.py,sha256=zZXHOFfXguhpQQJ5nyGe1VGWchOkanQUc8viV7nhQho,639
9
10
  maltoolbox/attackgraph/__init__.py,sha256=l7dJ7jOqpcj7PdsOKZt1NuXlPyjd6vZYvcXlj8Kq09w,297
10
- maltoolbox/attackgraph/attackgraph.py,sha256=OH5GP5v8_RPeHIxzwy2qlctAvMQ_8YgfIKtJwE6Pqqc,26309
11
+ maltoolbox/attackgraph/attackgraph.py,sha256=WBRwnVmQqwpjp3x_kSR8HH49MnlRIhF6tZ_r7nqTAJw,27162
11
12
  maltoolbox/attackgraph/node.py,sha256=7NAdEl40w6KMt_gKK1mP4SRAmmyCQGSTMaD-GHMNXHk,4186
12
13
  maltoolbox/attackgraph/analyzers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
14
  maltoolbox/language/__init__.py,sha256=RTTfhnCYa5PRGJCgDAWLpLLAzNCvNMn91UHNiOXszbg,490
14
- maltoolbox/language/languagegraph.py,sha256=hyz4Ecp2eWk9jlPplxHVOew3fUTcO6U7EuYnQCdY8j0,65386
15
+ maltoolbox/language/languagegraph.py,sha256=Tjqk5zv_37ghmepx8KBHPTH8tRlboaYcW836MiUS1d8,65140
15
16
  maltoolbox/language/compiler/__init__.py,sha256=Rbdeco6SWHyFw-VJfpxLRSZO3UMjJxPGenMR8OujVpA,15846
16
17
  maltoolbox/language/compiler/mal_lexer.py,sha256=TQvzEW7yCN0iY6Js5O6wCDFxSAE0_LAX4JVy96TnLro,14808
17
18
  maltoolbox/language/compiler/mal_parser.py,sha256=bVfYWRZyyhU-s2tJI-D_YwbVQkl3AHzdrD6LWM0BQbI,116108
@@ -22,11 +23,11 @@ maltoolbox/translators/networkx.py,sha256=v1JQAqO7st6-ktx5P3oy93DsL2SEUPly_3zcAL
22
23
  maltoolbox/translators/updater.py,sha256=mFmTT2GHCw6nsoHe_ChnvAHd5j6UxKnvAqLFDSziqC4,8566
23
24
  maltoolbox/visualization/__init__.py,sha256=7rrGclkGdP6LrxpfSh1esYFG_MnvnVruuEdUJI-DX-g,350
24
25
  maltoolbox/visualization/draw_io_utils.py,sha256=CgsD0HEFpxZ6ZIWtUZtMekdPB2Irtmvhz0TNEm7x1ig,14378
25
- maltoolbox/visualization/graphviz_utils.py,sha256=PENKhcpeQkxczzoRwOIVFe_olMhGBeuZg1d8JSqXUQQ,3909
26
+ maltoolbox/visualization/graphviz_utils.py,sha256=gA5WGnukZiis5RXIXtWyCy0QMFeSPucZMnFJSbbM8Xc,4687
26
27
  maltoolbox/visualization/neo4j_utils.py,sha256=R2Qm2gC5GDpfiPhhB3oymuBI2W580SRXGVtQuFRYiIA,3496
27
28
  maltoolbox/visualization/utils.py,sha256=EZWsxukO5hbRwGFW9GM9ZemKT-nYg-VMCup-SsntaQM,1480
28
- mal_toolbox-1.1.2.dist-info/METADATA,sha256=-iL2hPW6SS5B0ApSlJoj-ijJs7nc7petUWg0Hv961WM,6298
29
- mal_toolbox-1.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
30
- mal_toolbox-1.1.2.dist-info/entry_points.txt,sha256=oqby5O6cUP_OHCm70k_iYPA6UlbTBf7se1i3XwdK3uU,56
31
- mal_toolbox-1.1.2.dist-info/top_level.txt,sha256=phqRVLRKGdSUgRY03mcpi2cmbbDo5YGjkV4gkqHFFcM,11
32
- mal_toolbox-1.1.2.dist-info/RECORD,,
29
+ mal_toolbox-1.2.0.dist-info/METADATA,sha256=b2NVXhY7knTCzoQrNpJXrHAIChJir16tQBFt2v4rjJk,6696
30
+ mal_toolbox-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
+ mal_toolbox-1.2.0.dist-info/entry_points.txt,sha256=oqby5O6cUP_OHCm70k_iYPA6UlbTBf7se1i3XwdK3uU,56
32
+ mal_toolbox-1.2.0.dist-info/top_level.txt,sha256=phqRVLRKGdSUgRY03mcpi2cmbbDo5YGjkV4gkqHFFcM,11
33
+ mal_toolbox-1.2.0.dist-info/RECORD,,
maltoolbox/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- # MAL Toolbox v1.1.2
1
+ # MAL Toolbox v1.2.0
2
2
  # Copyright 2025, Andrei Buhaiu.
3
3
  #
4
4
  # Licensed under the Apache License, Version 2.0 (the "License");
@@ -19,7 +19,7 @@
19
19
  """
20
20
 
21
21
  __title__ = "maltoolbox"
22
- __version__ = "1.1.2"
22
+ __version__ = "1.2.0"
23
23
  __authors__ = [
24
24
  "Andrei Buhaiu",
25
25
  "Giuseppe Nebbione",
@@ -26,6 +26,8 @@ from ..language import (
26
26
  LanguageGraphAttackStep,
27
27
  disaggregate_attack_step_full_name,
28
28
  )
29
+
30
+ from ..str_utils import levenshtein_distance
29
31
  from ..model import Model
30
32
  from .node import AttackGraphNode
31
33
 
@@ -277,7 +279,7 @@ class AttackGraph:
277
279
  return cls._from_dict(serialized_attack_graph,
278
280
  lang_graph, model=model)
279
281
 
280
- def get_node_by_full_name(self, full_name: str) -> AttackGraphNode | None:
282
+ def get_node_by_full_name(self, full_name: str) -> AttackGraphNode:
281
283
  """Return the attack node that matches the full name provided.
282
284
 
283
285
  Arguments:
@@ -291,7 +293,13 @@ class AttackGraph:
291
293
 
292
294
  """
293
295
  logger.debug('Looking up node with full name "%s"', full_name)
294
- return self._full_name_to_node.get(full_name)
296
+ if full_name not in self._full_name_to_node:
297
+ similar_names = self._get_similar_full_names(full_name)
298
+ raise LookupError(
299
+ f'Could not find node with name "{full_name}". '
300
+ f'Did you mean: {", ".join(similar_names)}?'
301
+ )
302
+ return self._full_name_to_node[full_name]
295
303
 
296
304
  def _follow_field_expr_chain(
297
305
  self, target_assets: set[ModelAsset], expr_chain: ExpressionsChain
@@ -623,6 +631,19 @@ class AttackGraph:
623
631
  ag_node.children.add(target_node)
624
632
  target_node.parents.add(ag_node)
625
633
 
634
+ def _get_similar_full_names(self, q: str) -> list[str]:
635
+ """Return a list of node full names that are similar to `q`"""
636
+ shortest_dist = 100
637
+ similar_names = []
638
+ for full_name in self._full_name_to_node:
639
+ dist = levenshtein_distance(q, full_name)
640
+ if dist == shortest_dist:
641
+ similar_names.append(full_name)
642
+ elif dist < shortest_dist:
643
+ similar_names = [full_name]
644
+ shortest_dist = dist
645
+ return similar_names
646
+
626
647
  def regenerate_graph(self) -> None:
627
648
  """Regenerate the attack graph based on the original model instance and
628
649
  the MAL language specification provided at initialization.
@@ -80,7 +80,7 @@ class LanguageGraphAsset:
80
80
  field(default_factory=dict)
81
81
  info: dict = field(default_factory=dict)
82
82
  own_super_asset: LanguageGraphAsset | None = None
83
- own_sub_assets: set[LanguageGraphAsset] = field(default_factory=set)
83
+ own_sub_assets: list[LanguageGraphAsset] = field(default_factory=list)
84
84
  own_variables: dict = field(default_factory=dict)
85
85
  is_abstract: bool | None = None
86
86
 
@@ -115,7 +115,7 @@ class LanguageGraphAsset:
115
115
  return f'LanguageGraphAsset(name: "{self.name}")'
116
116
 
117
117
  def __hash__(self):
118
- return hash(self.name)
118
+ return id(self)
119
119
 
120
120
  def is_subasset_of(self, target_asset: LanguageGraphAsset) -> bool:
121
121
  """Check if an asset extends the target asset through inheritance.
@@ -383,7 +383,7 @@ class LanguageGraphAttackStep:
383
383
  detectors: dict = field(default_factory=dict)
384
384
 
385
385
  def __hash__(self):
386
- return hash(self.full_name)
386
+ return id(self)
387
387
 
388
388
  @property
389
389
  def children(self) -> dict[
@@ -773,7 +773,7 @@ class LanguageGraph:
773
773
  attack_steps={},
774
774
  info=asset['info'],
775
775
  own_super_asset=None,
776
- own_sub_assets=set(),
776
+ own_sub_assets=list(),
777
777
  own_variables={},
778
778
  is_abstract=asset['is_abstract']
779
779
  )
@@ -788,7 +788,8 @@ class LanguageGraph:
788
788
  msg = f'Super asset "{super_name}" for "{asset["name"]}" not found'
789
789
  logger.error(msg)
790
790
  raise LanguageGraphSuperAssetNotFoundError(msg)
791
- super_asset.own_sub_assets.add(asset_node)
791
+
792
+ super_asset.own_sub_assets.append(asset_node)
792
793
  asset_node.own_super_asset = super_asset
793
794
 
794
795
  # Associations
@@ -1454,7 +1455,7 @@ class LanguageGraph:
1454
1455
  raise LanguageGraphSuperAssetNotFoundError(
1455
1456
  msg % (asset_dict["superAsset"], asset_dict["name"]))
1456
1457
 
1457
- super_asset.own_sub_assets.add(asset)
1458
+ super_asset.own_sub_assets.append(asset)
1458
1459
  asset.own_super_asset = super_asset
1459
1460
 
1460
1461
  def _set_variables_for_assets(
@@ -1625,7 +1626,7 @@ class LanguageGraph:
1625
1626
  attack_steps={},
1626
1627
  info=asset_dict['meta'],
1627
1628
  own_super_asset=None,
1628
- own_sub_assets=set(),
1629
+ own_sub_assets=list(),
1629
1630
  own_variables={},
1630
1631
  is_abstract=asset_dict['isAbstract']
1631
1632
  )
@@ -1781,11 +1782,3 @@ class LanguageGraph:
1781
1782
  """
1782
1783
  self.assets = {}
1783
1784
  self._generate_graph()
1784
-
1785
- def __getstate__(self):
1786
- return self._to_dict()
1787
-
1788
- def __setstate__(self, state):
1789
- temp_lang_graph = self._from_dict(state)
1790
- self.assets = temp_lang_graph.assets
1791
- self.metadata = temp_lang_graph.metadata
maltoolbox/model.py CHANGED
@@ -310,27 +310,6 @@ class Model:
310
310
  "Try to upgrade it with 'maltoolbox upgrade-model'"
311
311
  ) from e
312
312
 
313
- def __getstate__(self):
314
- lang_state = self.lang_graph.__getstate__()
315
- state = self._to_dict()
316
- return {
317
- 'model_state': state,
318
- 'lang_graph': lang_state
319
- }
320
-
321
- def __setstate__(self, state):
322
- # Restore the language graph first
323
- lang_graph = LanguageGraph.__new__(LanguageGraph)
324
- lang_graph.__setstate__(state['lang_graph'])
325
- self.lang_graph = lang_graph
326
-
327
- # Restore the model state by creating a temporary model and copying attributes
328
- temp_model = self._from_dict(state['model_state'], self.lang_graph)
329
- self.name = temp_model.name
330
- self.assets = temp_model.assets
331
- self._name_to_asset = temp_model._name_to_asset
332
- self.maltoolbox_version = temp_model.maltoolbox_version
333
- self.next_id = temp_model.next_id
334
313
 
335
314
  class ModelAsset:
336
315
  def __init__(
@@ -0,0 +1,22 @@
1
+ """String related methods"""
2
+
3
+ def levenshtein_distance(a: str, b: str) -> int:
4
+ """Get distance between two strings"""
5
+ if a == b:
6
+ return 0
7
+ if not a:
8
+ return len(b)
9
+ if not b:
10
+ return len(a)
11
+
12
+ prev_row = list(range(len(b) + 1))
13
+ for i, ca in enumerate(a, start=1):
14
+ curr_row = [i]
15
+ for j, cb in enumerate(b, start=1):
16
+ insertions = prev_row[j] + 1
17
+ deletions = curr_row[j - 1] + 1
18
+ substitutions = prev_row[j - 1] + (ca != cb)
19
+ curr_row.append(min(insertions, deletions, substitutions))
20
+ prev_row = curr_row
21
+ return prev_row[-1]
22
+
@@ -1,3 +1,6 @@
1
+ from pathlib import Path
2
+ from os import PathLike
3
+ from typing import Optional
1
4
  import random
2
5
 
3
6
  import graphviz
@@ -36,8 +39,28 @@ graphviz_bright_colors = [
36
39
  ]
37
40
 
38
41
 
39
- def render_model(model: Model):
40
- """Render a model in graphviz, create pdf and open it"""
42
+ def _resolve_graphviz_path(path: Optional[PathLike], default_name: str):
43
+ """
44
+ Resolve a user-provided path into (directory, filename_without_ext).
45
+
46
+ - If path is None → ('.', default_name)
47
+ - If path is a directory → (path, default_name)
48
+ - If path is a file → (parent_directory, file_stem)
49
+ """
50
+ if path is None:
51
+ return ".", default_name
52
+
53
+ p = Path(path)
54
+
55
+ if p.is_dir():
56
+ return str(p), default_name
57
+
58
+ # It's a file path
59
+ return str(p.parent), p.stem
60
+
61
+
62
+ def render_model(model: Model, path: Optional[PathLike] = None, view=True):
63
+ """Render a model in graphviz, create PDF, and open it."""
41
64
  dot = graphviz.Digraph(model.name)
42
65
 
43
66
  # Create nodes
@@ -47,53 +70,54 @@ def render_model(model: Model):
47
70
  if not bg_color:
48
71
  bg_color = random.choice(graphviz_bright_colors)
49
72
  asset_type_colors[asset.lg_asset.name] = bg_color
50
- dot.node(
51
- str(asset.id), asset.name, style="filled", fillcolor=bg_color
52
- )
73
+
74
+ dot.node(str(asset.id), asset.name, style="filled", fillcolor=bg_color)
53
75
 
54
76
  # Create edges
55
77
  for from_asset in model.assets.values():
56
-
57
78
  for fieldname, to_assets in from_asset.associated_assets.items():
58
79
  for to_asset in to_assets:
59
- dot.edge(
60
- str(from_asset.id), str(to_asset.id), label=fieldname
61
- )
62
- dot.render(directory='.', view=True)
80
+ dot.edge(str(from_asset.id), str(to_asset.id), label=fieldname)
81
+
82
+ directory, filename = _resolve_graphviz_path(path, model.name)
83
+ dot.render(directory=directory, filename=f"{filename}.gv", view=view, format="pdf")
63
84
 
64
85
 
65
- def render_attack_graph(attack_graph: AttackGraph):
66
- """Render attack graph graphviz, create pdf and open it"""
86
+ def render_attack_graph(attack_graph: AttackGraph, path: Optional[PathLike] = None, view = True):
87
+ """Render attack graph graphviz, create PDF, and open it."""
67
88
  assert attack_graph.model, "Attack graph needs a model"
68
- dot = graphviz.Graph(attack_graph.model.name)
69
- dot.graph_attr['nodesep'] = '3.0' # Node separation
70
- dot.graph_attr['ratio'] = 'compress'
89
+
90
+ name = attack_graph.model.name + "-attack_graph"
91
+ dot = graphviz.Graph(name)
92
+ dot.graph_attr["nodesep"] = "3.0"
93
+ dot.graph_attr["ratio"] = "compress"
71
94
 
72
95
  # Create nodes
73
96
  asset_colors: dict[str, str] = {}
74
97
  for node in attack_graph.nodes.values():
75
98
  assert node.model_asset, "Node needs model"
99
+
76
100
  bg_color = asset_colors.get(node.model_asset.name)
77
101
  if not bg_color:
78
102
  bg_color = random.choice(graphviz_bright_colors)
79
103
  asset_colors[node.model_asset.name] = bg_color
80
- path_color = 'white'
104
+
81
105
  match node.type:
82
- case 'defense':
83
- path_color = 'blue'
84
- case 'or':
85
- path_color = 'red'
86
- case 'and':
87
- path_color = 'red'
88
- case 'exist':
89
- path_color = 'grey'
90
- case 'notExist':
91
- path_color = 'grey'
106
+ case "defense":
107
+ path_color = "blue"
108
+ case "or" | "and":
109
+ path_color = "red"
110
+ case "exist" | "notExist":
111
+ path_color = "grey"
92
112
  case t:
93
- raise ValueError(f'Type {t} not supported')
113
+ raise ValueError(f"Type {t} not supported")
94
114
 
95
115
  dot.node(
96
- str(node.id), node.full_name, style="filled", color=path_color, fillcolor=bg_color
116
+ str(node.id),
117
+ node.full_name,
118
+ style="filled",
119
+ color=path_color,
120
+ fillcolor=bg_color
97
121
  )
98
122
 
99
123
  # Create edges
@@ -101,4 +125,5 @@ def render_attack_graph(attack_graph: AttackGraph):
101
125
  for child in parent.children:
102
126
  dot.edge(str(parent.id), str(child.id))
103
127
 
104
- dot.render(directory='.', view=True)
128
+ directory, filename = _resolve_graphviz_path(path, name)
129
+ dot.render(directory=directory, filename=f"{filename}.gv", view=view, format="pdf")