mal-toolbox 0.2.0__py3-none-any.whl → 0.3.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.
@@ -5,17 +5,21 @@ from __future__ import annotations
5
5
  import copy
6
6
  import logging
7
7
  import json
8
+ import sys
9
+ import zipfile
8
10
 
9
11
  from itertools import chain
10
12
  from typing import TYPE_CHECKING
11
13
 
14
+ from .analyzers.apriori import calculate_viability_and_necessity
12
15
  from .node import AttackGraphNode
13
16
  from .attacker import Attacker
17
+ from .. import log_configs
14
18
  from ..exceptions import AttackGraphStepExpressionError, AttackGraphException
15
19
  from ..exceptions import LanguageGraphException
16
20
  from ..model import Model
17
21
  from ..language import (LanguageGraph, ExpressionsChain,
18
- disaggregate_attack_step_full_name)
22
+ LanguageGraphAttackStep, disaggregate_attack_step_full_name)
19
23
  from ..file_utils import (
20
24
  load_dict_from_json_file,
21
25
  load_dict_from_yaml_file,
@@ -25,20 +29,64 @@ from ..file_utils import (
25
29
 
26
30
  if TYPE_CHECKING:
27
31
  from typing import Any, Optional
32
+ from ..model import ModelAsset
28
33
 
29
34
  logger = logging.getLogger(__name__)
30
35
 
31
36
 
37
+ def create_attack_graph(
38
+ lang_file: str,
39
+ model_file: str,
40
+ attach_attackers=True,
41
+ calc_viability_and_necessity=True
42
+ ) -> AttackGraph:
43
+ """Create and return an attack graph
44
+
45
+ Args:
46
+ lang_file - path to language file (.mar or .mal)
47
+ model_file - path to model file (yaml or json)
48
+ attach_attackers - whether to run attach_attackers or not
49
+ calc_viability_and_necessity - whether run apriori calculations or not
50
+ """
51
+ try:
52
+ lang_graph = LanguageGraph.from_mar_archive(lang_file)
53
+ except zipfile.BadZipFile:
54
+ lang_graph = LanguageGraph.from_mal_spec(lang_file)
55
+
56
+ if log_configs['langspec_file']:
57
+ lang_graph.save_to_file(log_configs['langspec_file'])
58
+
59
+ instance_model = Model.load_from_file(model_file, lang_graph)
60
+
61
+ if log_configs['model_file']:
62
+ instance_model.save_to_file(log_configs['model_file'])
63
+
64
+ try:
65
+ attack_graph = AttackGraph(lang_graph, instance_model)
66
+ except AttackGraphStepExpressionError:
67
+ logger.error(
68
+ 'Attack graph generation failed when attempting '
69
+ 'to resolve attack step expression!'
70
+ )
71
+ sys.exit(1)
72
+
73
+ if attach_attackers:
74
+ attack_graph.attach_attackers()
75
+
76
+ if calc_viability_and_necessity:
77
+ calculate_viability_and_necessity(attack_graph)
78
+
79
+ return attack_graph
80
+
81
+
32
82
  class AttackGraph():
33
83
  """Graph representation of attack steps"""
34
84
  def __init__(self, lang_graph, model: Optional[Model] = None):
35
- self.nodes: list[AttackGraphNode] = []
36
- self.attackers: list[Attacker] = []
85
+ self.nodes: dict[int, AttackGraphNode] = {}
86
+ self.attackers: dict[int, Attacker] = {}
37
87
  # Dictionaries used in optimization to get nodes and attackers by id
38
88
  # or full name faster
39
- self._id_to_node: dict[int, AttackGraphNode] = {}
40
89
  self._full_name_to_node: dict[str, AttackGraphNode] = {}
41
- self._id_to_attacker: dict[int, Attacker] = {}
42
90
 
43
91
  self.model = model
44
92
  self.lang_graph = lang_graph
@@ -48,16 +96,17 @@ class AttackGraph():
48
96
  self._generate_graph()
49
97
 
50
98
  def __repr__(self) -> str:
51
- return f'AttackGraph({len(self.nodes)} nodes)'
99
+ return (f'AttackGraph(Number of nodes: {len(self.nodes)}, '
100
+ f'model: {self.model}, language: {self.lang_graph}')
52
101
 
53
102
  def _to_dict(self) -> dict:
54
103
  """Convert AttackGraph to dict"""
55
104
  serialized_attack_steps = {}
56
105
  serialized_attackers = {}
57
- for ag_node in self.nodes:
106
+ for ag_node in self.nodes.values():
58
107
  serialized_attack_steps[ag_node.full_name] =\
59
108
  ag_node.to_dict()
60
- for attacker in self.attackers:
109
+ for attacker in self.attackers.values():
61
110
  serialized_attackers[attacker.name] = attacker.to_dict()
62
111
  logger.debug('Serialized %d attack steps and %d attackers.' %
63
112
  (len(self.nodes), len(self.attackers))
@@ -76,34 +125,32 @@ class AttackGraph():
76
125
  copied_attackgraph = AttackGraph(self.lang_graph)
77
126
  copied_attackgraph.model = self.model
78
127
 
79
- copied_attackgraph.nodes = []
128
+ copied_attackgraph.nodes = {}
80
129
 
81
130
  # Deep copy nodes
82
- for node in self.nodes:
131
+ for node_id, node in self.nodes.items():
83
132
  copied_node = copy.deepcopy(node, memo)
84
- copied_attackgraph.nodes.append(copied_node)
133
+ copied_attackgraph.nodes[node_id] = copied_node
85
134
 
86
135
  # Re-link node references
87
- for node in self.nodes:
136
+ for node in self.nodes.values():
88
137
  if node.parents:
89
138
  memo[id(node)].parents = copy.deepcopy(node.parents, memo)
90
139
  if node.children:
91
140
  memo[id(node)].children = copy.deepcopy(node.children, memo)
92
141
 
93
- # Deep copy attackers and references to them
94
- copied_attackgraph.attackers = copy.deepcopy(self.attackers, memo)
142
+ # Deep copy attackers
143
+ for attacker_id, attacker in self.attackers.items():
144
+ copied_attacker = copy.deepcopy(attacker, memo)
145
+ copied_attackgraph.attackers[attacker_id] = copied_attacker
95
146
 
96
147
  # Re-link attacker references
97
- for node in self.nodes:
148
+ for node in self.nodes.values():
98
149
  if node.compromised_by:
99
150
  memo[id(node)].compromised_by = copy.deepcopy(
100
151
  node.compromised_by, memo)
101
152
 
102
153
  # Copy lookup dicts
103
- copied_attackgraph._id_to_attacker = \
104
- copy.deepcopy(self._id_to_attacker, memo)
105
- copied_attackgraph._id_to_node = \
106
- copy.deepcopy(self._id_to_node, memo)
107
154
  copied_attackgraph._full_name_to_node = \
108
155
  copy.deepcopy(self._full_name_to_node, memo)
109
156
 
@@ -144,8 +191,8 @@ class AttackGraph():
144
191
  if model and 'asset' in node_dict:
145
192
  node_asset = model.get_asset_by_name(node_dict['asset'])
146
193
  if node_asset is None:
147
- msg = ('Failed to find asset with id %s'
148
- 'when loading from attack graph dict')
194
+ msg = ('Failed to find asset with name "%s"'
195
+ ' when loading from attack graph dict')
149
196
  logger.error(msg, node_dict["asset"])
150
197
  raise LookupError(msg % node_dict["asset"])
151
198
 
@@ -154,13 +201,15 @@ class AttackGraph():
154
201
  node_dict['lang_graph_attack_step'])
155
202
  lg_attack_step = lang_graph.assets[lg_asset_name].\
156
203
  attack_steps[lg_attack_step_name]
157
- ag_node = AttackGraphNode(
158
- type=node_dict['type'],
159
- lang_graph_attack_step = lg_attack_step,
160
- name=node_dict['name'],
161
- ttc=node_dict['ttc'],
162
- asset=node_asset
204
+ ag_node = attack_graph.add_node(
205
+ lg_attack_step = lg_attack_step,
206
+ node_id = node_dict['id'],
207
+ model_asset = node_asset,
208
+ defense_status = node_dict.get('defense_status', None),
209
+ existence_status = node_dict.get('existence_status', None)
163
210
  )
211
+ ag_node.tags = set(node_dict.get('tags', []))
212
+ ag_node.extras = node_dict.get('extras', {})
164
213
 
165
214
  if node_asset:
166
215
  # Add AttackGraphNode to attack_step_nodes of asset
@@ -171,24 +220,10 @@ class AttackGraph():
171
220
  else:
172
221
  node_asset.attack_step_nodes = [ag_node]
173
222
 
174
- ag_node.defense_status = float(node_dict['defense_status']) if \
175
- 'defense_status' in node_dict else None
176
- ag_node.existence_status = node_dict['existence_status'] \
177
- == 'True' if 'existence_status' in node_dict else None
178
- ag_node.is_viable = node_dict['is_viable'] == 'True' if \
179
- 'is_viable' in node_dict else True
180
- ag_node.is_necessary = node_dict['is_necessary'] == 'True' if \
181
- 'is_necessary' in node_dict else True
182
- ag_node.tags = set(node_dict['tags']) if \
183
- 'tags' in node_dict else set()
184
- ag_node.extras = node_dict.get('extras', {})
185
-
186
- # Add AttackGraphNode to AttackGraph
187
- attack_graph.add_node(ag_node, node_id=node_dict['id'])
188
223
 
189
224
  # Re-establish links between nodes.
190
225
  for node_dict in serialized_attack_steps.values():
191
- _ag_node = attack_graph.get_node_by_id(node_dict['id'])
226
+ _ag_node = attack_graph.nodes[node_dict['id']]
192
227
  if not isinstance(_ag_node, AttackGraphNode):
193
228
  msg = ('Failed to find node with id %s when loading'
194
229
  ' attack graph from dict')
@@ -196,33 +231,36 @@ class AttackGraph():
196
231
  raise LookupError(msg % node_dict["id"])
197
232
  else:
198
233
  for child_id in node_dict['children']:
199
- child = attack_graph.get_node_by_id(int(child_id))
234
+ child = attack_graph.nodes[int(child_id)]
200
235
  if child is None:
201
236
  msg = ('Failed to find child node with id %s'
202
237
  ' when loading from attack graph from dict')
203
238
  logger.error(msg, child_id)
204
239
  raise LookupError(msg % child_id)
205
- _ag_node.children.append(child)
240
+ _ag_node.children.add(child)
206
241
 
207
242
  for parent_id in node_dict['parents']:
208
- parent = attack_graph.get_node_by_id(int(parent_id))
243
+ parent = attack_graph.nodes[int(parent_id)]
209
244
  if parent is None:
210
245
  msg = ('Failed to find parent node with id %s '
211
246
  'when loading from attack graph from dict')
212
247
  logger.error(msg, parent_id)
213
248
  raise LookupError(msg % parent_id)
214
- _ag_node.parents.append(parent)
249
+ _ag_node.parents.add(parent)
215
250
 
216
251
  for attacker in serialized_attackers.values():
217
252
  ag_attacker = Attacker(
218
253
  name = attacker['name'],
219
- entry_points = [],
220
- reached_attack_steps = []
254
+ entry_points = set(),
255
+ reached_attack_steps = set()
221
256
  )
222
257
  attack_graph.add_attacker(
223
258
  attacker = ag_attacker,
224
259
  attacker_id = int(attacker['id']),
225
- entry_points = attacker['entry_points'].keys(),
260
+ entry_points = [
261
+ int(node_id) # Convert to int since they can be strings
262
+ for node_id in attacker['entry_points'].keys()
263
+ ],
226
264
  reached_attack_steps = [
227
265
  int(node_id) # Convert to int since they can be strings
228
266
  for node_id in attacker['reached_attack_steps'].keys()
@@ -255,20 +293,6 @@ class AttackGraph():
255
293
  return cls._from_dict(serialized_attack_graph,
256
294
  lang_graph, model = model)
257
295
 
258
- def get_node_by_id(self, node_id: int) -> Optional[AttackGraphNode]:
259
- """
260
- Return the attack node that matches the id provided.
261
-
262
- Arguments:
263
- node_id - the id of the attack graph node we are looking for
264
-
265
- Return:
266
- The attack step node that matches the given id.
267
- """
268
-
269
- logger.debug('Looking up node with id %s', node_id)
270
- return self._id_to_node.get(node_id)
271
-
272
296
  def get_node_by_full_name(self, full_name: str) -> Optional[AttackGraphNode]:
273
297
  """
274
298
  Return the attack node that matches the full name provided.
@@ -284,20 +308,6 @@ class AttackGraph():
284
308
  logger.debug(f'Looking up node with full name "%s"', full_name)
285
309
  return self._full_name_to_node.get(full_name)
286
310
 
287
- def get_attacker_by_id(self, attacker_id: int) -> Optional[Attacker]:
288
- """
289
- Return the attacker that matches the id provided.
290
-
291
- Arguments:
292
- attacker_id - the id of the attacker we are looking for
293
-
294
- Return:
295
- The attacker that matches the given id.
296
- """
297
-
298
- logger.debug(f'Looking up attacker with id {attacker_id}')
299
- return self._id_to_attacker.get(attacker_id)
300
-
301
311
  def attach_attackers(self) -> None:
302
312
  """
303
313
  Create attackers and their entry point nodes and attach them to the
@@ -322,8 +332,8 @@ class AttackGraph():
322
332
 
323
333
  attacker = Attacker(
324
334
  name = attacker_info.name,
325
- entry_points = [],
326
- reached_attack_steps = []
335
+ entry_points = set(),
336
+ reached_attack_steps = set()
327
337
  )
328
338
  self.add_attacker(attacker)
329
339
 
@@ -340,12 +350,12 @@ class AttackGraph():
340
350
  continue
341
351
  attacker.compromise(ag_node)
342
352
 
343
- attacker.entry_points = list(attacker.reached_attack_steps)
353
+ attacker.entry_points = set(attacker.reached_attack_steps)
344
354
 
345
355
  def _follow_expr_chain(
346
356
  self,
347
357
  model: Model,
348
- target_assets: set[Any],
358
+ target_assets: set[ModelAsset],
349
359
  expr_chain: Optional[ExpressionsChain]
350
360
  ) -> set[Any]:
351
361
  """
@@ -420,10 +430,9 @@ class AttackGraph():
420
430
  new_target_assets = set()
421
431
  new_target_assets.update(
422
432
  *(
423
- model.get_associated_assets_by_field_name(
424
- asset, expr_chain.fieldname
425
- )
426
- for asset in target_assets
433
+ asset.associated_assets.get(
434
+ expr_chain.fieldname, set()
435
+ ) for asset in target_assets
427
436
  )
428
437
  )
429
438
  return new_target_assets
@@ -518,7 +527,7 @@ class AttackGraph():
518
527
  raise AttackGraphException(msg)
519
528
 
520
529
  # First, generate all of the nodes of the attack graph.
521
- for asset in self.model.assets:
530
+ for asset in self.model.assets.values():
522
531
 
523
532
  logger.debug(
524
533
  'Generating attack steps for asset %s which is of class %s.',
@@ -527,14 +536,7 @@ class AttackGraph():
527
536
 
528
537
  attack_step_nodes = []
529
538
 
530
- lang_graph_asset = self.lang_graph.assets[asset.type]
531
- if lang_graph_asset is None:
532
- raise LookupError(
533
- f'Failed to find asset with name \"{asset.type}\" in '
534
- 'the language graph.'
535
- )
536
-
537
- for attack_step in lang_graph_asset.attack_steps.values():
539
+ for attack_step in asset.lg_asset.attack_steps.values():
538
540
  logger.debug(
539
541
  'Generating attack step node for %s.', attack_step.name
540
542
  )
@@ -546,10 +548,9 @@ class AttackGraph():
546
548
  match (attack_step.type):
547
549
  case 'defense':
548
550
  # Set the defense status for defenses
549
- defense_status = getattr(asset, attack_step.name)
551
+ defense_status = asset.defenses[attack_step.name]
550
552
  logger.debug(
551
- 'Setting the defense status of \"%s\" to '
552
- '\"%s\".',
553
+ 'Setting the defense status of \"%s\" to "%s".',
553
554
  node_name, defense_status
554
555
  )
555
556
 
@@ -570,42 +571,40 @@ class AttackGraph():
570
571
  existence_status = True
571
572
  break
572
573
 
574
+ logger.debug(
575
+ 'Setting the existence status of \"%s\" to '
576
+ '%s.',
577
+ node_name, existence_status
578
+ )
579
+
573
580
  case _:
574
581
  pass
575
582
 
576
- ag_node = AttackGraphNode(
577
- type = attack_step.type,
578
- lang_graph_attack_step = attack_step,
579
- asset = asset,
580
- name = attack_step.name,
581
- ttc = attack_step.ttc,
582
- children = [],
583
- parents = [],
583
+ ag_node = self.add_node(
584
+ lg_attack_step = attack_step,
585
+ model_asset = asset,
584
586
  defense_status = defense_status,
585
- existence_status = existence_status,
586
- is_viable = True,
587
- is_necessary = True,
588
- tags = set(attack_step.tags),
589
- compromised_by = []
587
+ existence_status = existence_status
590
588
  )
591
589
  attack_step_nodes.append(ag_node)
592
- self.add_node(ag_node)
590
+
593
591
  asset.attack_step_nodes = attack_step_nodes
594
592
 
595
593
  # Then, link all of the nodes according to their associations.
596
- for ag_node in self.nodes:
594
+ for ag_node in self.nodes.values():
597
595
  logger.debug(
598
596
  'Determining children for attack step "%s"(%d)',
599
597
  ag_node.full_name,
600
598
  ag_node.id
601
599
  )
602
600
 
603
- if not ag_node.asset:
601
+ if not ag_node.model_asset:
604
602
  raise AttackGraphException('Attack graph node is missing '
605
603
  'asset link')
606
- lang_graph_asset = self.lang_graph.assets[ag_node.asset.type]
604
+ lang_graph_asset = self.lang_graph.assets[
605
+ ag_node.model_asset.type]
607
606
 
608
- lang_graph_attack_step = lang_graph_asset.attack_steps[\
607
+ lang_graph_attack_step = lang_graph_asset.attack_steps[
609
608
  ag_node.name]
610
609
 
611
610
  while lang_graph_attack_step:
@@ -613,7 +612,7 @@ class AttackGraph():
613
612
  for target_attack_step, expr_chain in child:
614
613
  target_assets = self._follow_expr_chain(
615
614
  self.model,
616
- set([ag_node.asset]),
615
+ set([ag_node.model_asset]),
617
616
  expr_chain
618
617
  )
619
618
 
@@ -653,8 +652,8 @@ class AttackGraph():
653
652
  target_node.id
654
653
  )
655
654
  )
656
- ag_node.children.append(target_node)
657
- target_node.parents.append(ag_node)
655
+ ag_node.children.add(target_node)
656
+ target_node.parents.add(ag_node)
658
657
  if lang_graph_attack_step.overrides:
659
658
  break
660
659
  lang_graph_attack_step = lang_graph_attack_step.inherits
@@ -666,37 +665,68 @@ class AttackGraph():
666
665
  the MAL language specification provided at initialization.
667
666
  """
668
667
 
669
- self.nodes = []
670
- self.attackers = []
668
+ self.nodes = {}
669
+ self.attackers = {}
671
670
  self._generate_graph()
672
671
 
673
672
  def add_node(
674
673
  self,
675
- node: AttackGraphNode,
676
- node_id: Optional[int] = None
677
- ) -> None:
678
- """Add a node to the graph
674
+ lg_attack_step: LanguageGraphAttackStep,
675
+ node_id: Optional[int] = None,
676
+ model_asset: Optional[ModelAsset] = None,
677
+ defense_status: Optional[float] = None,
678
+ existence_status: Optional[bool] = None
679
+ ) -> AttackGraphNode:
680
+ """Create and add a node to the graph
679
681
  Arguments:
680
- node - the node to add
681
- node_id - the id to assign to this node, usually used when loading
682
- an attack graph from a file
683
- """
684
- if logger.isEnabledFor(logging.DEBUG):
685
- # Avoid running json.dumps when not in debug
686
- logger.debug(f'Add node \"{node.full_name}\" '
687
- f'with id:{node_id}:\n' \
688
- + json.dumps(node.to_dict(), indent = 2))
682
+ lg_attack_step - the language graph attack step that corresponds
683
+ to the attack graph node to create
684
+ node_id - id to assign to the newly created node, usually
685
+ provided only when loading an existing attack
686
+ graph from a file. If not provided the id will
687
+ be set to the next highest id available.
688
+ model_asset - the model asset that corresponds to the attack
689
+ step node. While optional it is highly
690
+ recommended that this be provided. It should
691
+ only be ommitted if the model which was used to
692
+ generate the attack graph is not available when
693
+ loading an attack graph from a file.
694
+ defese_status - the defense status of the node. Only, relevant
695
+ for defense type nodes. A value between 0.0 and
696
+ 1.0 is expected.
697
+ existence_status - the existence status of the node. Only, relevant
698
+ for exist and notExist type nodes.
689
699
 
690
- if node.id in self._id_to_node:
700
+ Return:
701
+ The newly created attack step node.
702
+ """
703
+ node_id = node_id if node_id is not None else self.next_node_id
704
+ if node_id in self.nodes:
691
705
  raise ValueError(f'Node index {node_id} already in use.')
706
+ self.next_node_id = max(node_id + 1, self.next_node_id)
692
707
 
693
- node.id = node_id if node_id is not None else self.next_node_id
694
- self.next_node_id = max(node.id + 1, self.next_node_id)
708
+ if logger.isEnabledFor(logging.DEBUG):
709
+ # Avoid running json.dumps when not in debug
710
+ logger.debug('Create and add to attackgraph node of type "%s" '
711
+ 'with id:%d.\n' % (
712
+ lg_attack_step.full_name,
713
+ node_id
714
+ ))
715
+
716
+
717
+ node = AttackGraphNode(
718
+ node_id = node_id,
719
+ lg_attack_step = lg_attack_step,
720
+ model_asset = model_asset,
721
+ defense_status = defense_status,
722
+ existence_status = existence_status
723
+ )
695
724
 
696
- self.nodes.append(node)
697
- self._id_to_node[node.id] = node
725
+ self.nodes[node_id] = node
698
726
  self._full_name_to_node[node.full_name] = node
699
727
 
728
+ return node
729
+
700
730
  def remove_node(self, node: AttackGraphNode) -> None:
701
731
  """Remove node from attack graph
702
732
  Arguments:
@@ -709,11 +739,10 @@ class AttackGraph():
709
739
  child.parents.remove(node)
710
740
  for parent in node.parents:
711
741
  parent.children.remove(node)
712
- self.nodes.remove(node)
713
742
 
714
743
  if not isinstance(node.id, int):
715
744
  raise ValueError(f'Invalid node id.')
716
- del self._id_to_node[node.id]
745
+ del self.nodes[node.id]
717
746
  del self._full_name_to_node[node.full_name]
718
747
 
719
748
  def add_attacker(
@@ -748,12 +777,12 @@ class AttackGraph():
748
777
  )
749
778
 
750
779
  attacker.id = attacker_id or self.next_attacker_id
751
- if attacker.id in self._id_to_attacker:
780
+ if attacker.id in self.attackers:
752
781
  raise ValueError(f'Attacker index {attacker_id} already in use.')
753
782
 
754
783
  self.next_attacker_id = max(attacker.id + 1, self.next_attacker_id)
755
784
  for node_id in reached_attack_steps:
756
- node = self.get_node_by_id(node_id)
785
+ node = self.nodes[node_id]
757
786
  if node:
758
787
  attacker.compromise(node)
759
788
  else:
@@ -762,16 +791,15 @@ class AttackGraph():
762
791
  logger.error(msg, node_id)
763
792
  raise AttackGraphException(msg % node_id)
764
793
  for node_id in entry_points:
765
- node = self.get_node_by_id(int(node_id))
794
+ node = self.nodes[node_id]
766
795
  if node:
767
- attacker.entry_points.append(node)
796
+ attacker.entry_points.add(node)
768
797
  else:
769
798
  msg = ("Could not find node with id %d"
770
799
  "in attacker entrypoints.")
771
800
  logger.error(msg, node_id)
772
801
  raise AttackGraphException(msg % node_id)
773
- self.attackers.append(attacker)
774
- self._id_to_attacker[attacker.id] = attacker
802
+ self.attackers[attacker.id] = attacker
775
803
 
776
804
  def remove_attacker(self, attacker: Attacker):
777
805
  """Remove attacker from attack graph
@@ -785,7 +813,6 @@ class AttackGraph():
785
813
  attacker.id)
786
814
  for node in attacker.reached_attack_steps:
787
815
  attacker.undo_compromise(node)
788
- self.attackers.remove(attacker)
789
816
  if not isinstance(attacker.id, int):
790
817
  raise ValueError(f'Invalid attacker id.')
791
- del self._id_to_attacker[attacker.id]
818
+ del self.attackers[attacker.id]