-
Notifications
You must be signed in to change notification settings - Fork 3
Add LookupDict for faster and simpler lookups #105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,12 +2,12 @@ | |
| MAL-Toolbox Attack Graph Module | ||
| """ | ||
| from __future__ import annotations | ||
| import copy | ||
| import logging | ||
| import json | ||
| import sys | ||
| import zipfile | ||
|
|
||
| from copy import deepcopy | ||
| from itertools import chain | ||
| from typing import TYPE_CHECKING | ||
|
|
||
|
|
@@ -20,10 +20,11 @@ | |
| from ..model import Model | ||
| from ..language import (LanguageGraph, ExpressionsChain, | ||
| LanguageGraphAttackStep, disaggregate_attack_step_full_name) | ||
| from ..file_utils import ( | ||
| from ..utils import ( | ||
| load_dict_from_json_file, | ||
| load_dict_from_yaml_file, | ||
| save_dict_to_file | ||
| save_dict_to_file, | ||
| LookupDict, | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -82,11 +83,10 @@ def create_attack_graph( | |
| class AttackGraph(): | ||
| """Graph representation of attack steps""" | ||
| def __init__(self, lang_graph, model: Optional[Model] = None): | ||
| self.nodes: dict[int, AttackGraphNode] = {} | ||
| self.nodes: LookupDict[int, AttackGraphNode] = LookupDict() | ||
| self.attackers: dict[int, Attacker] = {} | ||
| # Dictionaries used in optimization to get nodes and attackers by id | ||
| # or full name faster | ||
| self._full_name_to_node: dict[str, AttackGraphNode] = {} | ||
|
|
||
| self.model = model | ||
| self.lang_graph = lang_graph | ||
|
|
@@ -123,38 +123,24 @@ def __deepcopy__(self, memo): | |
| return memo[id(self)] | ||
|
|
||
| copied_attackgraph = AttackGraph(self.lang_graph) | ||
| copied_attackgraph.model = self.model | ||
|
|
||
| copied_attackgraph.nodes = {} | ||
| memo[id(self)] = copied_attackgraph | ||
|
|
||
| # Deep copy nodes | ||
| for node_id, node in self.nodes.items(): | ||
| copied_node = copy.deepcopy(node, memo) | ||
| copied_attackgraph.nodes[node_id] = copied_node | ||
| copied_attackgraph.model = self.model | ||
| copied_attackgraph.nodes = deepcopy(self.nodes, memo) | ||
| copied_attackgraph.attackers = deepcopy(self.attackers, memo) | ||
|
|
||
| # Re-link node references | ||
| for node in self.nodes.values(): | ||
| nid = id(node) | ||
| if node.parents: | ||
| memo[id(node)].parents = copy.deepcopy(node.parents, memo) | ||
| memo[nid].parents = deepcopy(node.parents, memo) | ||
| if node.children: | ||
| memo[id(node)].children = copy.deepcopy(node.children, memo) | ||
|
|
||
| # Deep copy attackers | ||
| for attacker_id, attacker in self.attackers.items(): | ||
| copied_attacker = copy.deepcopy(attacker, memo) | ||
| copied_attackgraph.attackers[attacker_id] = copied_attacker | ||
|
|
||
| # Re-link attacker references | ||
| for node in self.nodes.values(): | ||
| memo[nid].children = deepcopy(node.children, memo) | ||
| if node.compromised_by: | ||
| memo[id(node)].compromised_by = copy.deepcopy( | ||
| node.compromised_by, memo) | ||
| memo[nid].compromised_by = deepcopy(node.compromised_by, memo) | ||
|
|
||
| # Copy lookup dicts | ||
| copied_attackgraph._full_name_to_node = \ | ||
| copy.deepcopy(self._full_name_to_node, memo) | ||
| copied_attackgraph.attackers = deepcopy(self.attackers, memo) | ||
|
|
||
| # Copy counters | ||
| copied_attackgraph.next_node_id = self.next_node_id | ||
| copied_attackgraph.next_attacker_id = self.next_attacker_id | ||
|
|
||
|
|
@@ -189,8 +175,9 @@ def _from_dict( | |
| # Recreate asset links if model is available. | ||
| node_asset = None | ||
| if model and 'asset' in node_dict: | ||
| node_asset = model.get_asset_by_name(node_dict['asset']) | ||
| if node_asset is None: | ||
| try: | ||
| node_asset = model.assets.fetch("name", node_dict['asset']) | ||
| except KeyError: | ||
| msg = ('Failed to find asset with name "%s"' | ||
| ' when loading from attack graph dict') | ||
| logger.error(msg, node_dict["asset"]) | ||
|
|
@@ -293,21 +280,6 @@ def load_from_file( | |
| return cls._from_dict(serialized_attack_graph, | ||
| lang_graph, model = model) | ||
|
|
||
| def get_node_by_full_name(self, full_name: str) -> Optional[AttackGraphNode]: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I personally liked the methods get_node_by_id and get_node_by_full_name. They made it clear what attributes you could fetch a node by. Now the user would do
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All valid attributes are supported. Removing those methods is what this PR does. Having them back and then going through the lookup dict undoes this. I don't like the get methods as:
As long as the user understands what fetch() or lookup() do, they won't have difficulty in reading the code. They are also cognitively more light as you don't have to read and read long lines, long method names etc. You read
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am ok with removing the get_by_id and get_by_full_name, but we should still have one method in attack graph which uses the LookupDict so users don't have to interact with it directly.
Perhaps, but I think having a method that wraps around nodes.fetch() called something like Just to illustrate my thinking: From the users perspective if we have no method in AttackGraph:
If we instead had a method I am skeptical of making the API less explicit by having common methods in instance variables (.nodes).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where documentation helps, with examples how you do things. And IDE completion is also useful here, it will show a fetch or lookup method with full docstrings and type hints. I do not favor adding such a method. This code: I think does not add much compared to: especially once you have seen, read and used it once.
Also, adding a get_ method would provide users with two ways to do the same thing which is not ideal. A further motivation for doing this PR was that this LookupDict will be used elsewhere too, not just for nodes. Nodes is just the first application of this. For example, in model we have
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I would add wrappers around the dicts if they were dicts. That is how it worked before with get_node_by_id and get_node_by_full_name works like this I believe (the dicts were private variables of the AttackGraph). I agree the LookupDict could be used in more places. But I mean that in the AttackGraph it has only one use, which is to fetch nodes by attribute values / key values, which is the context the method (name) would add. But I don't seem to be able to convince you, so maybe I will just back off.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have a different programming style. I believe the changes will increase the quality of the python code, but that may not be the case at all. As a maintainer, you have a greater say, but I would hate to write back that kind of code. Maybe you want to add these changes or maybe @andrewbwm can help break the deadlock? But many of the changes I propose are of this kind, removing things that I find verbose and not pythonic and accepting some but not others will lead to an inconsistent style in the code. My mistake is that I didn't sync with you first before embarking on this. I was driven by a desire to make the code run faster and to do that it helps to have it simplified and more "linear".
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that is it. I don't think it is easy to say who is right or wrong, we just prioritize different things. Yes, maybe @andrewbwm can chime in to give some third perspective!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @andrewbwm 🔔 or 🔕 ? |
||
| """ | ||
| Return the attack node that matches the full name provided. | ||
|
|
||
| Arguments: | ||
| full_name - the full name of the attack graph node we are looking | ||
| for | ||
|
|
||
| Return: | ||
| The attack step node that matches the given full name. | ||
| """ | ||
|
|
||
| logger.debug(f'Looking up node with full name "%s"', full_name) | ||
| return self._full_name_to_node.get(full_name) | ||
|
|
||
| def attach_attackers(self) -> None: | ||
| """ | ||
| Create attackers and their entry point nodes and attach them to the | ||
|
|
@@ -340,7 +312,7 @@ def attach_attackers(self) -> None: | |
| for (asset, attack_steps) in attacker_info.entry_points: | ||
| for attack_step in attack_steps: | ||
| full_name = asset.name + ':' + attack_step | ||
| ag_node = self.get_node_by_full_name(full_name) | ||
| ag_node = self.nodes.fetch("full_name", full_name) | ||
| if not ag_node: | ||
| logger.warning( | ||
| 'Failed to find attacker entry point ' | ||
|
|
@@ -620,8 +592,8 @@ def _generate_graph(self) -> None: | |
| if target_asset is not None: | ||
| target_node_full_name = target_asset.name + \ | ||
| ':' + target_attack_step.name | ||
| target_node = self.get_node_by_full_name( | ||
| target_node_full_name) | ||
| target_node = self.nodes.fetch("full_name", | ||
| target_node_full_name) | ||
| if target_node is None: | ||
| msg = ('Failed to find target node ' | ||
| '"%s" to link with for attack ' | ||
|
|
@@ -665,7 +637,7 @@ def regenerate_graph(self) -> None: | |
| the MAL language specification provided at initialization. | ||
| """ | ||
|
|
||
| self.nodes = {} | ||
| self.nodes = LookupDict() | ||
| self.attackers = {} | ||
| self._generate_graph() | ||
|
|
||
|
|
@@ -713,7 +685,6 @@ def add_node( | |
| node_id | ||
| )) | ||
|
|
||
|
|
||
| node = AttackGraphNode( | ||
| node_id = node_id, | ||
| lg_attack_step = lg_attack_step, | ||
|
|
@@ -722,8 +693,7 @@ def add_node( | |
| existence_status = existence_status | ||
| ) | ||
|
|
||
| self.nodes[node_id] = node | ||
| self._full_name_to_node[node.full_name] = node | ||
| self.nodes[node.id] = node | ||
|
|
||
| return node | ||
|
|
||
|
|
@@ -740,10 +710,7 @@ def remove_node(self, node: AttackGraphNode) -> None: | |
| for parent in node.parents: | ||
| parent.children.remove(node) | ||
|
|
||
| if not isinstance(node.id, int): | ||
| raise ValueError(f'Invalid node id.') | ||
| del self.nodes[node.id] | ||
| del self._full_name_to_node[node.full_name] | ||
| self.nodes.pop(node.id, None) | ||
|
|
||
| def add_attacker( | ||
| self, | ||
|
|
||
This file was deleted.
Uh oh!
There was an error while loading. Please reload this page.