mal-toolbox 0.0.28__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.28.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.28.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 +84 -25
  10. maltoolbox/attackgraph/attackgraph.py +503 -215
  11. maltoolbox/attackgraph/node.py +92 -31
  12. maltoolbox/attackgraph/query.py +125 -19
  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.28.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 -282
  35. {mal_toolbox-0.0.28.dist-info → mal_toolbox-0.1.12.dist-info}/AUTHORS +0 -0
  36. {mal_toolbox-0.0.28.dist-info → mal_toolbox-0.1.12.dist-info}/LICENSE +0 -0
  37. {mal_toolbox-0.0.28.dist-info → mal_toolbox-0.1.12.dist-info}/top_level.txt +0 -0
@@ -2,50 +2,54 @@
2
2
  MAL-Toolbox Attack Graph Node Dataclass
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+ import copy
5
7
  from dataclasses import field, dataclass
6
- from typing import Any, List, Optional
8
+ from typing import Any, Optional
7
9
 
8
- from maltoolbox.attackgraph.attacker import Attacker
10
+ from . import Attacker
9
11
 
10
12
  @dataclass
11
13
  class AttackGraphNode:
12
- id: str
14
+ """Node part of AttackGraph"""
13
15
  type: str
14
16
  name: str
15
- ttc: dict
16
- asset: Any = None
17
- children: List['AttackGraphNode'] = field(default_factory=list)
18
- parents: List['AttackGraphNode'] = field(default_factory=list)
17
+ ttc: Optional[dict] = None
18
+ id: Optional[int] = None
19
+ asset: Optional[Any] = None
20
+ children: list[AttackGraphNode] = field(default_factory=list)
21
+ parents: list[AttackGraphNode] = field(default_factory=list)
19
22
  defense_status: Optional[float] = None
20
23
  existence_status: Optional[bool] = None
21
24
  is_viable: bool = True
22
25
  is_necessary: bool = True
23
- compromised_by: List['Attacker'] = field(default_factory=list)
24
- extra: Optional[dict] = None
26
+ compromised_by: list[Attacker] = field(default_factory=list)
25
27
  mitre_info: Optional[str] = None
26
- tags: Optional[List[str]] = None
27
- observations: Optional[dict] = None
28
+ tags: list[str] = field(default_factory=lambda: [])
28
29
  attributes: Optional[dict] = None
29
- attacker: Optional['Attacker'] = None
30
30
 
31
- def __repr__(self):
32
- return f'AttackGraphNode({self.id}, {self.type}, {self.name})'
31
+ # Optional extra metadata for AttackGraphNode
32
+ extras: dict = field(default_factory=dict)
33
33
 
34
- def to_dict(self):
35
- node_dict = {
34
+ def to_dict(self) -> dict:
35
+ """Convert node to dictionary"""
36
+ node_dict: dict = {
36
37
  'id': self.id,
37
38
  'type': self.type,
38
39
  'name': self.name,
39
40
  'ttc': self.ttc,
40
- 'children': [child.id for child in self.children],
41
- 'parents': [parent.id for parent in self.parents],
42
- 'compromised_by': ['Attacker:' + attacker.id for attacker in \
41
+ 'children': {},
42
+ 'parents': {},
43
+ 'compromised_by': [attacker.name for attacker in \
43
44
  self.compromised_by]
44
45
  }
45
46
 
47
+ for child in self.children:
48
+ node_dict['children'][child.id] = child.full_name
49
+ for parent in self.parents:
50
+ node_dict['parents'][parent.id] = parent.full_name
46
51
  if self.asset is not None:
47
- node_dict['asset'] = self.asset.metaconcept + ':' \
48
- + str(self.asset.id)
52
+ node_dict['asset'] = str(self.asset.name)
49
53
  if self.defense_status is not None:
50
54
  node_dict['defense_status'] = str(self.defense_status)
51
55
  if self.existence_status is not None:
@@ -58,19 +62,64 @@ class AttackGraphNode:
58
62
  node_dict['mitre_info'] = str(self.mitre_info)
59
63
  if self.tags:
60
64
  node_dict['tags'] = str(self.tags)
61
- if self.observations is not None:
62
- node_dict['observations'] = self.observations
65
+ if self.extras:
66
+ node_dict['extras'] = self.extras
63
67
 
64
68
  return node_dict
65
69
 
66
- def is_compromised(self):
70
+ def __repr__(self) -> str:
71
+ return str(self.to_dict())
72
+
73
+ def __deepcopy__(self, memo) -> AttackGraphNode:
74
+ """Deep copy an attackgraph node
75
+
76
+ The deepcopy will copy over node specific information, such as type,
77
+ name, etc., but it will not copy attack graph relations such as
78
+ parents, children, or attackers it is compromised by. These references
79
+ should be recreated when deepcopying the attack graph itself.
80
+
81
+ """
82
+
83
+ # Check if the object is already in the memo dictionary
84
+ if id(self) in memo:
85
+ return memo[id(self)]
86
+
87
+ copied_node = AttackGraphNode(
88
+ self.type,
89
+ self.name,
90
+ self.ttc,
91
+ self.id,
92
+ self.asset,
93
+ [],
94
+ [],
95
+ self.defense_status,
96
+ self.existence_status,
97
+ self.is_viable,
98
+ self.is_necessary,
99
+ [],
100
+ self.mitre_info,
101
+ [],
102
+ {},
103
+ {}
104
+ )
105
+
106
+ copied_node.tags = copy.deepcopy(self.tags, memo)
107
+ copied_node.attributes = copy.deepcopy(self.attributes, memo)
108
+ copied_node.extras = copy.deepcopy(self.extras, memo)
109
+
110
+ # Remember that self was already copied
111
+ memo[id(self)] = copied_node
112
+
113
+ return copied_node
114
+
115
+ def is_compromised(self) -> bool:
67
116
  """
68
117
  Return True if any attackers have compromised this node.
69
118
  False, otherwise.
70
119
  """
71
120
  return len(self.compromised_by) > 0
72
121
 
73
- def is_compromised_by(self, attacker):
122
+ def is_compromised_by(self, attacker: Attacker) -> bool:
74
123
  """
75
124
  Return True if the attacker given as an argument has compromised this
76
125
  node.
@@ -81,7 +130,7 @@ class AttackGraphNode:
81
130
  """
82
131
  return attacker in self.compromised_by
83
132
 
84
- def compromise(self, attacker):
133
+ def compromise(self, attacker: Attacker) -> None:
85
134
  """
86
135
  Have the attacker given as a parameter compromise this node.
87
136
 
@@ -90,7 +139,7 @@ class AttackGraphNode:
90
139
  """
91
140
  attacker.compromise(self)
92
141
 
93
- def undo_compromise(self, attacker):
142
+ def undo_compromise(self, attacker: Attacker) -> None:
94
143
  """
95
144
  Remove the attacker given as a parameter from the list of attackers
96
145
  that have compromised this node.
@@ -101,7 +150,7 @@ class AttackGraphNode:
101
150
  """
102
151
  attacker.undo_compromise(self)
103
152
 
104
- def is_enabled_defense(self):
153
+ def is_enabled_defense(self) -> bool:
105
154
  """
106
155
  Return True if this node is a defense node and it is enabled and not
107
156
  suppressed via tags.
@@ -111,12 +160,24 @@ class AttackGraphNode:
111
160
  'suppress' not in self.tags and \
112
161
  self.defense_status == 1.0
113
162
 
114
- def is_available_defense(self):
163
+ def is_available_defense(self) -> bool:
115
164
  """
116
165
  Return True if this node is a defense node and it is not fully enabled
117
- and not suppressed via tags.
118
- False, otherwise.
166
+ and not suppressed via tags. False otherwise.
119
167
  """
120
168
  return self.type == 'defense' and \
121
169
  'suppress' not in self.tags and \
122
170
  self.defense_status != 1.0
171
+
172
+ @property
173
+ def full_name(self) -> str:
174
+ """
175
+ Return the full name of the attack step. This is a combination of the
176
+ asset name to which the attack step belongs and attack step name
177
+ itself.
178
+ """
179
+ if self.asset:
180
+ full_name = self.asset.name + ':' + self.name
181
+ else:
182
+ full_name = str(self.id) + ':' + self.name
183
+ return full_name
@@ -4,15 +4,20 @@ MAL-Toolbox Attack Graph Query Submodule
4
4
  This submodule contains functions that analyze the information present in the
5
5
  attack graph, but do not alter the structure or nodes in any way.
6
6
  """
7
-
7
+ from __future__ import annotations
8
8
  import logging
9
+ from typing import TYPE_CHECKING
10
+
11
+ from .attackgraph import AttackGraph, Attacker
9
12
 
10
- from maltoolbox.attackgraph import attackgraph
11
- from maltoolbox.attackgraph import attacker
13
+ if TYPE_CHECKING:
14
+ from .attackgraph import AttackGraphNode
12
15
 
13
16
  logger = logging.getLogger(__name__)
14
17
 
15
- def is_node_traversable_by_attacker(node, attacker) -> bool:
18
+ def is_node_traversable_by_attacker(
19
+ node: AttackGraphNode, attacker: Attacker
20
+ ) -> bool:
16
21
  """
17
22
  Return True or False depending if the node specified is traversable
18
23
  for the attacker given.
@@ -22,13 +27,31 @@ def is_node_traversable_by_attacker(node, attacker) -> bool:
22
27
  attacker - the attacker whose traversability we are interested in
23
28
  """
24
29
 
25
- logger.debug(f'Evaluate if {node.id}, of type \'{node.type}\', is '\
26
- f'traversable by Attacker {attacker.id}')
30
+ logger.debug(
31
+ 'Evaluate if "%s"(%d), of type "%s", is traversable by Attacker '
32
+ '"%s"(%d)',
33
+ node.full_name,
34
+ node.id,
35
+ node.type,
36
+ attacker.name,
37
+ attacker.id
38
+ )
27
39
  if not node.is_viable:
40
+ logger.debug(
41
+ '"%s"(%d) is not traversable because it is non-viable',
42
+ node.full_name,
43
+ node.id,
44
+ )
28
45
  return False
29
46
 
30
47
  match(node.type):
31
48
  case 'or':
49
+ logger.debug(
50
+ '"%s"(%d) is traversable because it is viable and '
51
+ 'of type "or".',
52
+ node.full_name,
53
+ node.id
54
+ )
32
55
  return True
33
56
 
34
57
  case 'and':
@@ -37,18 +60,49 @@ def is_node_traversable_by_attacker(node, attacker) -> bool:
37
60
  not parent.is_compromised_by(attacker):
38
61
  # If the parent is not present in the attacks steps
39
62
  # already reached and is necessary.
63
+ logger.debug(
64
+ '"%s"(%d) is not traversable because while it is '
65
+ 'viable, and of type "and", its necessary parent '
66
+ '"%s(%d)" has not already been compromised.',
67
+ node.full_name,
68
+ node.id,
69
+ parent.full_name,
70
+ parent.id
71
+ )
40
72
  return False
73
+ logger.debug(
74
+ '"%s"(%d) is traversable because it is viable, '
75
+ 'of type "and", and all of its necessary parents have '
76
+ 'already been compromised.',
77
+ node.full_name,
78
+ node.id
79
+ )
41
80
  return True
42
81
 
43
- case 'exist' | 'notExist' | 'defense':
82
+ case 'exist' | 'notExist' | 'defense':
83
+ logger.warning(
84
+ 'Nodes of type "exist", "notExist", and "defense" are never '
85
+ 'marked as traversable. However, we do not normally check '
86
+ 'if they are traversable. Node "%s"(%d) of type "%s" was '
87
+ 'checked for traversability.',
88
+ node.full_name,
89
+ node.id,
90
+ node.type
91
+ )
44
92
  return False
45
93
 
46
94
  case _:
47
- logger.error(f'Unknown node type {node.type}.')
95
+ logger.error(
96
+ 'Node "%s"(%d) has an unknown type "%s".',
97
+ node.full_name,
98
+ node.id,
99
+ node.type
100
+ )
48
101
  return False
49
102
 
50
- def get_attack_surface(graph: attackgraph.AttackGraph,
51
- attacker: attacker.Attacker):
103
+ def get_attack_surface(
104
+ attacker: Attacker
105
+ ) -> list[AttackGraphNode]:
52
106
  """
53
107
  Get the current attack surface of an attacker. This includes all of the
54
108
  viable children nodes of already reached attack steps that are of 'or'
@@ -56,21 +110,74 @@ def get_attack_surface(graph: attackgraph.AttackGraph,
56
110
  parents in the attack steps reached.
57
111
 
58
112
  Arguments:
59
- graph - the attack graph
60
113
  attacker - the Attacker whose attack surface is sought
61
114
  """
62
- logger.debug(f'Get the attack surface for Attacker {attacker.id}.')
115
+ logger.debug(
116
+ 'Get the attack surface for Attacker "%s"(%d).',
117
+ attacker.name,
118
+ attacker.id
119
+ )
63
120
  attack_surface = []
64
121
  for attack_step in attacker.reached_attack_steps:
65
- logger.debug('Determine attack surface stemming from '
66
- f'{attack_step.id} for Attacker {attacker.id}.')
122
+ logger.debug(
123
+ 'Determine attack surface stemming from '
124
+ '"%s"(%d) for Attacker "%s"(%d).',
125
+ attack_step.full_name,
126
+ attack_step.id,
127
+ attacker.name,
128
+ attacker.id
129
+ )
67
130
  for child in attack_step.children:
68
131
  if is_node_traversable_by_attacker(child, attacker) and \
69
132
  child not in attack_surface:
70
133
  attack_surface.append(child)
71
134
  return attack_surface
72
135
 
73
- def get_defense_surface(graph: attackgraph.AttackGraph):
136
+ def update_attack_surface_add_nodes(
137
+ attacker: Attacker,
138
+ current_attack_surface: list[AttackGraphNode],
139
+ nodes: list[AttackGraphNode]
140
+ ) -> list[AttackGraphNode]:
141
+ """
142
+ Update the attack surface of an attacker with the new attack step nodes
143
+ provided to see if any of their children can be added.
144
+
145
+ Arguments:
146
+ attacker - the Attacker whose attack surface is sought
147
+ current_attack_surface - the current attack surface that we wish to
148
+ expand
149
+ nodes - the newly compromised attack step nodes that we
150
+ wish to see if any of their children should be
151
+ added to the attack surface
152
+ """
153
+ logger.debug('Update the attack surface for Attacker "%s"(%d).',
154
+ attacker.name,
155
+ attacker.id)
156
+ attack_surface = current_attack_surface
157
+ for attack_step in nodes:
158
+ logger.debug(
159
+ 'Determine attack surface stemming from "%s"(%d) '
160
+ 'for Attacker "%s"(%d).',
161
+ attack_step.full_name,
162
+ attack_step.id,
163
+ attacker.name,
164
+ attacker.id
165
+ )
166
+ for child in attack_step.children:
167
+ is_traversable = is_node_traversable_by_attacker(child, attacker)
168
+ if is_traversable and child not in attack_surface:
169
+ logger.debug(
170
+ 'Add node "%s"(%d) to the attack surface of '
171
+ 'Attacker "%s"(%d).',
172
+ child.full_name,
173
+ child.id,
174
+ attacker.name,
175
+ attacker.id
176
+ )
177
+ attack_surface.append(child)
178
+ return attack_surface
179
+
180
+ def get_defense_surface(graph: AttackGraph) -> list[AttackGraphNode]:
74
181
  """
75
182
  Get the defense surface. All non-suppressed defense steps that are not
76
183
  already fully enabled.
@@ -78,10 +185,10 @@ def get_defense_surface(graph: attackgraph.AttackGraph):
78
185
  Arguments:
79
186
  graph - the attack graph
80
187
  """
81
- logger.debug(f'Get the defense surface.')
188
+ logger.debug('Get the defense surface.')
82
189
  return [node for node in graph.nodes if node.is_available_defense()]
83
190
 
84
- def get_enabled_defenses(graph: attackgraph.AttackGraph):
191
+ def get_enabled_defenses(graph: AttackGraph) -> list[AttackGraphNode]:
85
192
  """
86
193
  Get the defenses already enabled. All non-suppressed defense steps that
87
194
  are already fully enabled.
@@ -89,6 +196,5 @@ def get_enabled_defenses(graph: attackgraph.AttackGraph):
89
196
  Arguments:
90
197
  graph - the attack graph
91
198
  """
92
- logger.debug(f'Get the enabled defenses.')
199
+ logger.debug('Get the enabled defenses.')
93
200
  return [node for node in graph.nodes if node.is_enabled_defense()]
94
-
maltoolbox/default.conf CHANGED
@@ -1,16 +1,17 @@
1
1
  [logging]
2
2
  output_dir = tmp
3
+ log_level =
3
4
  log_file = %(output_dir)s/log.txt
4
- attackgraph_file = %(output_dir)s/attackgraph.json
5
- model_file = %(output_dir)s/model.json
6
- langspec_file = %(output_dir)s/langspec_file.json
5
+ attackgraph_file = %(output_dir)s/attackgraph.yml
6
+ model_file = %(output_dir)s/model.yml
7
+ langspec_file = %(output_dir)s/langspec_file.yml
7
8
 
8
9
  [input]
9
10
  model_file =
10
11
  lang_spec_file =
11
12
 
12
13
  [neo4j]
13
- uri=
14
- username=
15
- password=
16
- dbname=
14
+ uri =
15
+ username =
16
+ password =
17
+ dbname =
@@ -0,0 +1,45 @@
1
+ class MalToolboxException(Exception):
2
+ """Base exception for all other maltoolbox exceptions to inherit from."""
3
+ pass
4
+
5
+ class LanguageGraphException(MalToolboxException):
6
+ """Base exception for all language-graph related exceptions."""
7
+ pass
8
+
9
+ class LanguageGraphSuperAssetNotFoundError(LanguageGraphException):
10
+ """Asset's super asset not found in language graph during attack graph construction."""
11
+ pass
12
+
13
+ class LanguageGraphAssociationError(LanguageGraphException):
14
+ """Error in building an association.
15
+
16
+ For example, right or left-hand side asset of association missing in
17
+ language graph.
18
+ """
19
+ pass
20
+
21
+ class LanguageGraphStepExpressionError(LanguageGraphException):
22
+ """A target asset cannot be linked with for a step expression."""
23
+ pass
24
+
25
+ class AttackGraphException(MalToolboxException):
26
+ """Base exception for all attack-graph related exceptions."""
27
+ pass
28
+
29
+ class AttackGraphStepExpressionError(AttackGraphException):
30
+ """A target attack step cannot be linked with for a step expression."""
31
+ pass
32
+
33
+
34
+ class ModelException(MalToolboxException):
35
+ """Base Exception for all Model related exceptions"""
36
+ pass
37
+
38
+
39
+ class ModelAssociationException(ModelException):
40
+ """Exception related to associations in Model"""
41
+ pass
42
+
43
+ class DuplicateModelAssociationError(ModelException):
44
+ """Associations should be unique as part of Model"""
45
+ pass
@@ -0,0 +1,66 @@
1
+ """Utily functions for file handling"""
2
+
3
+ import json
4
+ import yaml
5
+ from python_jsonschema_objects.literals import LiteralValue
6
+
7
+ def save_dict_to_json_file(filename: str, serialized_object: dict) -> None:
8
+ """Save serialized object to a json file.
9
+
10
+ Arguments:
11
+ filename - the name of the output file
12
+ data - dict to output as json
13
+ """
14
+
15
+ with open(filename, 'w', encoding='utf-8') as f:
16
+ json.dump(serialized_object, f, indent=4)
17
+
18
+
19
+ def save_dict_to_yaml_file(filename: str, serialized_object: dict) -> None:
20
+ """Save serialized object to a yaml file.
21
+
22
+ Arguments:
23
+ filename - the name of the output file
24
+ data - dict to output as yaml
25
+ """
26
+
27
+ # Handle Literal values from jsonschema_objects
28
+ yaml.add_multi_representer(
29
+ LiteralValue,
30
+ lambda dumper, data: dumper.represent_data(data._value),
31
+ yaml.SafeDumper
32
+ )
33
+
34
+ with open(filename, 'w', encoding='utf-8') as f:
35
+ yaml.dump(serialized_object, f, Dumper=yaml.SafeDumper)
36
+
37
+
38
+ def load_dict_from_yaml_file(filename: str) -> dict:
39
+ """Open json file and read as dict"""
40
+ with open(filename, 'r', encoding='utf-8') as file:
41
+ object_dict = yaml.safe_load(file)
42
+ return object_dict
43
+
44
+
45
+ def load_dict_from_json_file(filename: str) -> dict:
46
+ """Open yaml file and read as dict"""
47
+ with open(filename, 'r', encoding='utf-8') as file:
48
+ object_dict = json.loads(file.read())
49
+ return object_dict
50
+
51
+
52
+ def save_dict_to_file(filename: str, dictionary: dict) -> None:
53
+ """Save serialized object to json or yaml file
54
+ depending on file extension.
55
+
56
+ Arguments:
57
+ filename - the name of the output file
58
+ dictionary - the dict to save to the file
59
+ """
60
+
61
+ if filename.endswith(('.yml', '.yaml')):
62
+ save_dict_to_yaml_file(filename, dictionary)
63
+ elif filename.endswith('.json'):
64
+ save_dict_to_json_file(filename, dictionary)
65
+ else:
66
+ raise ValueError('Unknown file extension, expected json/yml/yaml')
File without changes