55import ast
66import sys
77from types import CodeType
8- from typing import Any , ClassVar , Tuple , List
8+ from typing import Any , ClassVar
99from collections .abc import Iterable
1010
1111import builtins
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
0 commit comments