Skip to content

Commit 9ef9103

Browse files
committed
First pass at converting threats to python classes
1 parent 86ccaa8 commit 9ef9103

20 files changed

Lines changed: 2427 additions & 1909 deletions

File tree

pytm/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"Assumption",
66
"Boundary",
77
"Classification",
8+
"Likelihood",
9+
"Severity",
810
"TLSVersion",
911
"Data",
1012
"Dataflow",
@@ -33,7 +35,7 @@
3335
from .pytm import var
3436

3537
# Import from new Pydantic models
36-
from .enums import Action, Classification, DatastoreType, Lifetime, TLSVersion
38+
from .enums import Action, Classification, DatastoreType, Lifetime, Likelihood, Severity, TLSVersion
3739
from .base import Assumption, Controls
3840
from .element import Element
3941
from .data import Data

pytm/enums.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,30 @@ def label(self):
8181
return self.value.lower().replace("_", " ")
8282

8383

84+
class Likelihood(OrderedEnum):
85+
"""Likelihood of a threat occurring."""
86+
87+
LOW = 1
88+
MEDIUM = 2
89+
HIGH = 3
90+
91+
def label(self):
92+
return self.name.capitalize()
93+
94+
95+
class Severity(OrderedEnum):
96+
"""Severity level of a threat."""
97+
98+
VERY_LOW = 1
99+
LOW = 2
100+
MEDIUM = 3
101+
HIGH = 4
102+
VERY_HIGH = 5
103+
104+
def label(self):
105+
return self.name.replace("_", " ").capitalize()
106+
107+
84108
class TLSVersion(OrderedEnum):
85109
"""TLS/SSL version levels."""
86110

pytm/threat.py

Lines changed: 51 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import ast
66
import sys
77
from types import CodeType
8-
from typing import Any, ClassVar, Tuple, List
8+
from typing import Any, ClassVar
99
from collections.abc import Iterable
1010

1111
import builtins
@@ -14,6 +14,7 @@
1414
BaseModel,
1515
Field,
1616
ConfigDict,
17+
field_validator,
1718
model_validator,
1819
PrivateAttr,
1920
)
@@ -130,8 +131,8 @@ def visit_Name(self, node: ast.Name) -> Any: # noqa: D401
130131
return None
131132

132133
@staticmethod
133-
def _attribute_chain(node: ast.Attribute) -> List[str]:
134-
chain: List[str] = [node.attr]
134+
def _attribute_chain(node: ast.Attribute) -> list[str]:
135+
chain: list[str] = [node.attr]
135136
current = node.value
136137
while isinstance(current, ast.Attribute):
137138
if isinstance(current.attr, str) and current.attr.startswith("__"):
@@ -184,13 +185,21 @@ class Threat(BaseModel):
184185
default="", description="Likelihood of the threat occurring"
185186
)
186187
severity: str = Field(default="", description="Severity level of the threat")
188+
189+
@field_validator("likelihood", "severity", mode="before")
190+
@classmethod
191+
def _coerce_enum_to_str(cls, v: Any) -> str:
192+
"""Accept Likelihood/Severity enum values and coerce them to their label strings."""
193+
if hasattr(v, "label"):
194+
return v.label()
195+
return v
187196
mitigations: str = Field(
188197
default="", description="Possible mitigations for the threat"
189198
)
190199
prerequisites: str = Field(default="", description="Prerequisites for the threat")
191200
example: str = Field(default="", description="Example of the threat")
192201
references: str = Field(default="", description="References for the threat")
193-
target: Tuple = Field(default=(), description="Target classes for this threat")
202+
target: tuple = Field(default=(), description="Target classes for this threat")
194203

195204
_compiled_condition: CodeType | None = PrivateAttr(default=None)
196205
_eval_globals: ClassVar[dict[str, Any] | None] = None
@@ -210,26 +219,33 @@ def _normalize_input(cls, data: Any) -> Any:
210219
if "Likelihood Of Attack" in data:
211220
data.setdefault("likelihood", data.pop("Likelihood Of Attack"))
212221

213-
# Normalise target to a tuple
214-
target = data.get("target", "Element")
215-
if isinstance(target, str) or not isinstance(target, Iterable):
216-
target = (target,)
217-
else:
218-
target = tuple(target)
219-
220-
# Resolve target name strings to actual Python classes
221-
resolved = []
222-
for name in target:
223-
if isinstance(name, type):
224-
resolved.append(name)
222+
# Normalise target to a tuple — only when explicitly passed (e.g. JSON
223+
# loading). Class-level tuple defaults on Python-native Threat subclasses
224+
# are already correct types and must not be overridden here.
225+
if "target" in data:
226+
target = data["target"]
227+
if isinstance(target, str) or not isinstance(target, Iterable):
228+
target = (target,)
225229
else:
226-
klass = getattr(sys.modules.get("pytm"), name, None)
227-
resolved.append(klass if klass is not None else name)
228-
data["target"] = tuple(resolved)
230+
target = tuple(target)
231+
232+
# Resolve target name strings to actual Python classes
233+
resolved = []
234+
for name in target:
235+
if isinstance(name, type):
236+
resolved.append(name)
237+
else:
238+
klass = getattr(sys.modules.get("pytm"), name, None)
239+
resolved.append(klass if klass is not None else name)
240+
data["target"] = tuple(resolved)
229241

230242
return data
231243

232244
def model_post_init(self, __context: Any) -> None: # noqa: D401
245+
# Skip string compilation when _check_condition is overridden in a subclass.
246+
if type(self)._check_condition is not Threat._check_condition:
247+
return
248+
233249
if not self.condition:
234250
self._compiled_condition = None
235251
return
@@ -248,13 +264,6 @@ def model_post_init(self, __context: Any) -> None: # noqa: D401
248264
f"Invalid syntax in condition for threat {self.id}: {exc}"
249265
) from exc
250266

251-
def _safeset(self, attr: str, value) -> None:
252-
"""Safely set an attribute value."""
253-
try:
254-
setattr(self, attr, value)
255-
except (ValueError, TypeError):
256-
pass
257-
258267
def __repr__(self):
259268
return (
260269
f"<{self.__module__}.{type(self).__name__}({self.id}) at {hex(id(self))}>"
@@ -301,32 +310,37 @@ def _allowed_global_names(cls) -> set[str]:
301310
globals_dict = cls._build_eval_globals()
302311
return {key for key in globals_dict.keys() if key != "__builtins__"}
303312

304-
def apply(self, target):
305-
"""Apply the threat condition to a target."""
306-
# Check if target matches any of the target types
313+
def _check_condition(self, target) -> bool:
314+
"""Evaluate whether this threat applies to the given target.
315+
316+
Override this method in subclasses to define conditions natively in Python
317+
instead of using string eval. The base implementation uses the compiled
318+
string condition (for JSON-loaded threats).
319+
"""
320+
if self._compiled_condition is None:
321+
return False
322+
323+
globals_dict = dict(self._build_eval_globals())
324+
return bool(eval(self._compiled_condition, globals_dict, {"target": target}))
325+
326+
def apply(self, target) -> bool:
327+
"""Return True if this threat applies to the given target element."""
307328
if self.target:
308329
target_matches = False
309330
for target_type in self.target:
310331
if isinstance(target_type, str):
311-
# String comparison for backward compatibility
312332
if target_type == type(target).__name__:
313333
target_matches = True
314334
break
315335
elif isinstance(target_type, type):
316-
# Class type comparison
317336
if isinstance(target, target_type):
318337
target_matches = True
319338
break
320339

321340
if not target_matches:
322341
return False
323342

324-
if self._compiled_condition is None:
325-
return False
326-
327343
try:
328-
globals_dict = dict(self._build_eval_globals())
329-
locals_dict = {"target": target}
330-
return bool(eval(self._compiled_condition, globals_dict, locals_dict))
344+
return bool(self._check_condition(target))
331345
except Exception:
332346
return False

pytm/threatlib/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Threat library — auto-exports all Threat subclasses from category modules.
2+
3+
Import threat classes directly from this package without needing to know
4+
which file they live in::
5+
6+
from pytm.threatlib import INP01, CR01, AA01
7+
"""
8+
9+
import inspect
10+
import importlib
11+
import pkgutil
12+
13+
from pytm.threat import Threat
14+
15+
for _finder, _mod_name, _ispkg in pkgutil.iter_modules(__path__, prefix=__name__ + "."):
16+
_module = importlib.import_module(_mod_name)
17+
for _cls_name, _cls in inspect.getmembers(_module, inspect.isclass):
18+
if issubclass(_cls, Threat) and _cls is not Threat and _cls.__module__ == _module.__name__:
19+
globals()[_cls_name] = _cls

0 commit comments

Comments
 (0)