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
@@ -2,47 +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 to_dict(self):
32
- node_dict = {
31
+ # Optional extra metadata for AttackGraphNode
32
+ extras: dict = field(default_factory=dict)
33
+
34
+ def to_dict(self) -> dict:
35
+ """Convert node to dictionary"""
36
+ node_dict: dict = {
33
37
  'id': self.id,
34
38
  'type': self.type,
35
39
  'name': self.name,
36
40
  'ttc': self.ttc,
37
- 'children': [child.id for child in self.children],
38
- 'parents': [parent.id for parent in self.parents],
39
- 'compromised_by': ['Attacker:' + attacker.id for attacker in \
41
+ 'children': {},
42
+ 'parents': {},
43
+ 'compromised_by': [attacker.name for attacker in \
40
44
  self.compromised_by]
41
45
  }
42
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
43
51
  if self.asset is not None:
44
- node_dict['asset'] = self.asset.metaconcept + ':' \
45
- + str(self.asset.id)
52
+ node_dict['asset'] = str(self.asset.name)
46
53
  if self.defense_status is not None:
47
54
  node_dict['defense_status'] = str(self.defense_status)
48
55
  if self.existence_status is not None:
@@ -55,7 +62,122 @@ class AttackGraphNode:
55
62
  node_dict['mitre_info'] = str(self.mitre_info)
56
63
  if self.tags:
57
64
  node_dict['tags'] = str(self.tags)
58
- if self.observations is not None:
59
- node_dict['observations'] = self.observations
65
+ if self.extras:
66
+ node_dict['extras'] = self.extras
60
67
 
61
68
  return node_dict
69
+
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:
116
+ """
117
+ Return True if any attackers have compromised this node.
118
+ False, otherwise.
119
+ """
120
+ return len(self.compromised_by) > 0
121
+
122
+ def is_compromised_by(self, attacker: Attacker) -> bool:
123
+ """
124
+ Return True if the attacker given as an argument has compromised this
125
+ node.
126
+ False, otherwise.
127
+
128
+ Arguments:
129
+ attacker - the attacker we are interested in
130
+ """
131
+ return attacker in self.compromised_by
132
+
133
+ def compromise(self, attacker: Attacker) -> None:
134
+ """
135
+ Have the attacker given as a parameter compromise this node.
136
+
137
+ Arguments:
138
+ attacker - the attacker that will compromise the node
139
+ """
140
+ attacker.compromise(self)
141
+
142
+ def undo_compromise(self, attacker: Attacker) -> None:
143
+ """
144
+ Remove the attacker given as a parameter from the list of attackers
145
+ that have compromised this node.
146
+
147
+ Arguments:
148
+ attacker - the attacker that we wish to remove from the compromised
149
+ list.
150
+ """
151
+ attacker.undo_compromise(self)
152
+
153
+ def is_enabled_defense(self) -> bool:
154
+ """
155
+ Return True if this node is a defense node and it is enabled and not
156
+ suppressed via tags.
157
+ False, otherwise.
158
+ """
159
+ return self.type == 'defense' and \
160
+ 'suppress' not in self.tags and \
161
+ self.defense_status == 1.0
162
+
163
+ def is_available_defense(self) -> bool:
164
+ """
165
+ Return True if this node is a defense node and it is not fully enabled
166
+ and not suppressed via tags. False otherwise.
167
+ """
168
+ return self.type == 'defense' and \
169
+ 'suppress' not in self.tags and \
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,33 +27,82 @@ 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':
35
58
  for parent in node.parents:
36
59
  if parent.is_necessary and \
37
- attacker not in parent.compromised_by:
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,12 +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.')
82
- return (node for node in graph.nodes if node.type == 'defense' and \
83
- 'suppress' not in node.tags and \
84
- node.defense_status != 1.0)
188
+ logger.debug('Get the defense surface.')
189
+ return [node for node in graph.nodes if node.is_available_defense()]
85
190
 
86
- def get_enabled_defenses(graph: attackgraph.AttackGraph):
191
+ def get_enabled_defenses(graph: AttackGraph) -> list[AttackGraphNode]:
87
192
  """
88
193
  Get the defenses already enabled. All non-suppressed defense steps that
89
194
  are already fully enabled.
@@ -91,8 +196,5 @@ def get_enabled_defenses(graph: attackgraph.AttackGraph):
91
196
  Arguments:
92
197
  graph - the attack graph
93
198
  """
94
- logger.debug(f'Get the enabled defenses.')
95
- return (node for node in graph.nodes if node.type == 'defense' and \
96
- 'suppress' not in node.tags and \
97
- node.defense_status == 1.0)
98
-
199
+ logger.debug('Get the enabled defenses.')
200
+ return [node for node in graph.nodes if node.is_enabled_defense()]
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