Skip to content

Commit 4b9cf7d

Browse files
committed
Add ttc sampling and expected value to AttackGraphNode
1 parent 13940b4 commit 4b9cf7d

File tree

3 files changed

+169
-1
lines changed

3 files changed

+169
-1
lines changed

maltoolbox/attackgraph/node.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import copy
77
from functools import cached_property
88
from typing import TYPE_CHECKING
9+
import numpy as np
10+
import math
911

1012
if TYPE_CHECKING:
1113
from typing import Any, Optional
1214
from . import Attacker
13-
from ..language import LanguageGraphAttackStep, Detector
15+
from ..language import LanguageGraphAttackStep
1416
from ..model import ModelAsset
1517

1618
class AttackGraphNode:
@@ -181,6 +183,101 @@ def is_available_defense(self) -> bool:
181183
'suppress' not in self.tags and \
182184
self.defense_status != 1.0
183185

186+
def ttc_sample(self) -> float:
187+
"""Sample a value from ttc distribution for a node
188+
189+
TTC Distributions in MAL:
190+
https://mal-lang.org/mal-langspec/apidocs/org.mal_lang.langspec/org/mal_lang/langspec/ttc/TtcDistribution.html
191+
"""
192+
193+
def sample(exponential: float, bernoulli=1.0):
194+
"""
195+
Generate a random sample for the given distributions.
196+
If bernoulli distribution is not given, the sample will
197+
simply be from an exponential.
198+
199+
If the Bernoulli trial fails (0), return infinity (impossible).
200+
If the Bernoulli trial succeeds (1), return sample from
201+
exponential distribution.
202+
"""
203+
204+
# If bernoulli is set to 1, the sample is just exponential
205+
if np.random.choice([0, 1], p=[1 - bernoulli, bernoulli]):
206+
return np.random.exponential(scale=1 / exponential)
207+
return math.inf
208+
209+
if self.type == "defense":
210+
# Defenses have no ttc
211+
return 0
212+
213+
distribution = self.ttc.get('name')
214+
s = math.nan
215+
if distribution == "EasyAndCertain":
216+
s = sample(exponential=1)
217+
elif distribution == "EasyAndUncertain":
218+
s = sample(exponential=1, bernoulli=0.5)
219+
elif distribution == "HardAndCertain":
220+
s = sample(exponential=0.1)
221+
elif distribution == "HardAndUncertain":
222+
s = sample(exponential=0.1, bernoulli=0.5)
223+
elif distribution == "VeryHardAndCertain":
224+
s = sample(exponential=0.01)
225+
elif distribution == "VeryHardAndUncertain":
226+
s = sample(exponential=0.01, bernoulli=0.5)
227+
elif distribution == "Exponential":
228+
scale = float(self.ttc['arguments'][0])
229+
s = sample(exponential=scale)
230+
else:
231+
raise ValueError(f"Unknown TTC distribution: {distribution}")
232+
233+
return s
234+
235+
def ttc_expected_value(self) -> float:
236+
"""Returns the expected value of the ttc distribution for a node.
237+
238+
TTC Distributions in MAL:
239+
https://mal-lang.org/mal-langspec/apidocs/org.mal_lang.langspec/org/mal_lang/langspec/ttc/TtcDistribution.html
240+
"""
241+
242+
def expected_value(exponential: float, bernoulli=1.0):
243+
"""Compute expected value for given distributions.
244+
245+
If bernoulli distribution is 0, the expected value is infinite.
246+
Otherwise, the expected value is the expected value of the
247+
exponential distribution divided by the probability of the
248+
bernoulli distribution.
249+
"""
250+
if bernoulli == 0:
251+
# If Bernoulli always blocks, expectation is infinite
252+
return math.inf
253+
254+
# Conditional expectation
255+
return (1 / exponential) / bernoulli
256+
257+
if self.type == "defense":
258+
# Defenses have no ttc
259+
return 0
260+
261+
distribution = self.ttc["name"]
262+
e = math.nan
263+
if distribution == "EasyAndCertain":
264+
e = expected_value(exponential=1.0)
265+
elif distribution == "EasyAndUncertain":
266+
e = expected_value(exponential=1.0, bernoulli=0.5)
267+
elif distribution == "HardAndCertain":
268+
e = expected_value(exponential=0.1)
269+
elif distribution == "HardAndUncertain":
270+
e = expected_value(exponential=0.1, bernoulli=0.5)
271+
elif distribution == "VeryHardAndCertain":
272+
e = expected_value(exponential=0.01)
273+
elif distribution == "VeryHardAndUncertain":
274+
e = expected_value(exponential=0.01, bernoulli=0.5)
275+
elif distribution == "Exponential":
276+
scale = float(self.ttc["arguments"][0])
277+
e = expected_value(exponential=scale)
278+
else:
279+
raise ValueError(f"Unknown TTC distribution: {distribution}")
280+
return e
184281

185282
@property
186283
def full_name(self) -> str:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"antlr4-python3-runtime",
1818
"docopt",
1919
"PyYAML",
20+
"numpy",
2021
]
2122
license = {text = "Apache Software License"}
2223
keywords = ["mal"]

tests/attackgraph/test_node.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unit tests for AttackGraphNode functionality"""
22

3+
from maltoolbox.model import Model
34
from maltoolbox.attackgraph.node import AttackGraphNode
45
from maltoolbox.attackgraph.attacker import Attacker
56
from maltoolbox.attackgraph.attackgraph import AttackGraph
@@ -83,3 +84,72 @@ def test_attackgraphnode(dummy_lang_graph: LanguageGraph):
8384
# Node 1 is not a defense
8485
assert not node1.is_available_defense()
8586
assert not node1.is_enabled_defense()
87+
88+
89+
def test_ttc_node(model: Model):
90+
"""Test TTC calculation for nodes"""
91+
92+
app = model.add_asset('Application')
93+
creds = model.add_asset('Credentials')
94+
user = model.add_asset('User')
95+
identity = model.add_asset('Identity')
96+
vuln = model.add_asset('SoftwareVulnerability')
97+
98+
identity.add_associated_assets('credentials', {creds})
99+
user.add_associated_assets('userIds', {identity})
100+
app.add_associated_assets('highPrivAppIAMs', {identity})
101+
app.add_associated_assets('vulnerabilities', {vuln})
102+
103+
attack_graph = AttackGraph(model.lang_graph, model)
104+
105+
nodes_with_ttc = [
106+
node for node in attack_graph.nodes.values()
107+
if node.ttc is not None and node.ttc['name'] != 'Disabled'
108+
]
109+
110+
assert nodes_with_ttc, "No nodes with ttc set"
111+
for node in nodes_with_ttc:
112+
# Make sure we can sample and that returned
113+
# values are non-negative numbers
114+
sample = node.ttc_sample()
115+
expected = node.ttc_expected_value()
116+
assert sample >= 0, "Sampled TTC is negative"
117+
assert expected >= 0, "Expected TTC is negative"
118+
119+
easy_and_certain_nodes = [
120+
node for node in nodes_with_ttc
121+
if node.ttc['name'] == 'EasyAndCertain'
122+
]
123+
assert easy_and_certain_nodes
124+
for node in easy_and_certain_nodes:
125+
assert node.ttc_expected_value() == 1.0
126+
127+
128+
# TODO EasyAndUncertain does not exist in coreLang
129+
# TODO HardAndCertain does not exist in coreLang
130+
131+
hard_and_uncertain_nodes = [
132+
node for node in nodes_with_ttc
133+
if node.ttc['name'] == 'HardAndUncertain'
134+
]
135+
assert hard_and_uncertain_nodes
136+
for node in hard_and_uncertain_nodes:
137+
assert node.ttc_expected_value() == 20.0
138+
139+
# TODO: VeryHardAndCertain does not exist in coreLang
140+
141+
very_hard_and_uncertain_nodes = [
142+
node for node in nodes_with_ttc
143+
if node.ttc['name'] == 'VeryHardAndUncertain'
144+
]
145+
assert very_hard_and_uncertain_nodes
146+
for node in very_hard_and_uncertain_nodes:
147+
assert node.ttc_expected_value() == 200.0
148+
149+
exponential_nodes = [
150+
node for node in nodes_with_ttc
151+
if node.ttc['name'] == 'Exponential'
152+
]
153+
assert exponential_nodes
154+
for node in exponential_nodes:
155+
assert node.ttc_expected_value() == (1/node.ttc["arguments"][0])

0 commit comments

Comments
 (0)