44
55DETECTIONS_ROOT = Path ("detections/sentinel" )
66
7- REQUIRED_FIELDS = [
7+ # Full schema for active detections
8+ REQUIRED_ACTIVE_FIELDS = [
89 "title" ,
910 "id" ,
1011 "status" ,
2728 "tags" ,
2829]
2930
31+ # Lighter schema for deprecated detections
32+ REQUIRED_DEPRECATED_FIELDS = [
33+ "title" ,
34+ "id" ,
35+ "status" ,
36+ "description" ,
37+ "author" ,
38+ "date" ,
39+ "logsource" ,
40+ "lifecycle" ,
41+ ]
42+
3043ALLOWED_STATUS = {"experimental" , "testing" , "stable" , "production" , "deprecated" }
3144ALLOWED_LIFECYCLE = {"experimental" , "testing" , "production" , "deprecated" }
3245ALLOWED_SEVERITY = {"low" , "medium" , "high" , "critical" }
@@ -37,26 +50,57 @@ def load_yaml(path: Path):
3750 return yaml .safe_load (f )
3851
3952
40- def validate_file (path : Path ):
53+ def is_deprecated_rule (path : Path , data : dict ) -> bool :
54+ if "deprecated" in path .parts :
55+ return True
56+ lifecycle = str (data .get ("lifecycle" , "" )).strip ().lower ()
57+ status = str (data .get ("status" , "" )).strip ().lower ()
58+ return lifecycle == "deprecated" or status == "deprecated"
59+
60+
61+ def validate_common_fields (path : Path , data : dict ):
4162 errors = []
4263
43- try :
44- data = load_yaml ( path )
45- except Exception as exc :
46- return [ f" { path } : YAML parse error: { exc } " ]
64+ status = str ( data . get ( "status" , "" )). strip (). lower ()
65+ lifecycle = str ( data . get ( "lifecycle" , "" )). strip (). lower ( )
66+ title = str ( data . get ( "title" , "" )). strip ()
67+ rule_id = str ( data . get ( "id" , "" )). strip ()
4768
48- if not isinstance (data , dict ):
49- return [f"{ path } : root YAML object must be a dictionary" ]
69+ if title == "" :
70+ errors .append (f"{ path } : 'title' must not be empty" )
71+
72+ deprecated = is_deprecated_rule (path , data )
73+
74+ if not deprecated and not rule_id .startswith ("SENT-" ):
75+ errors .append (f"{ path } : 'id' should start with 'SENT-'" )
76+
77+ if status and status not in ALLOWED_STATUS :
78+ errors .append (
79+ f"{ path } : invalid status '{ data .get ('status' )} '. Allowed: { sorted (ALLOWED_STATUS )} "
80+ )
5081
51- for field in REQUIRED_FIELDS :
82+ if lifecycle and lifecycle not in ALLOWED_LIFECYCLE :
83+ errors .append (
84+ f"{ path } : invalid lifecycle '{ data .get ('lifecycle' )} '. Allowed: { sorted (ALLOWED_LIFECYCLE )} "
85+ )
86+
87+ if "logsource" in data and not isinstance (data .get ("logsource" ), dict ):
88+ errors .append (f"{ path } : 'logsource' must be a dictionary" )
89+
90+ return errors
91+
92+
93+ def validate_active_rule (path : Path , data : dict ):
94+ errors = []
95+
96+ for field in REQUIRED_ACTIVE_FIELDS :
5297 if field not in data :
5398 errors .append (f"{ path } : missing required field '{ field } '" )
5499
55100 if errors :
56101 return errors
57102
58- if not isinstance (data .get ("logsource" ), dict ):
59- errors .append (f"{ path } : 'logsource' must be a dictionary" )
103+ errors .extend (validate_common_fields (path , data ))
60104
61105 if not isinstance (data .get ("tags" ), list ):
62106 errors .append (f"{ path } : 'tags' must be a list" )
@@ -76,20 +120,7 @@ def validate_file(path: Path):
76120 if not isinstance (data .get ("validation" ), list ):
77121 errors .append (f"{ path } : 'validation' must be a list" )
78122
79- status = str (data .get ("status" , "" )).strip ().lower ()
80- lifecycle = str (data .get ("lifecycle" , "" )).strip ().lower ()
81123 severity = str (data .get ("severity" , "" )).strip ().lower ()
82-
83- if status not in ALLOWED_STATUS :
84- errors .append (
85- f"{ path } : invalid status '{ data .get ('status' )} '. Allowed: { sorted (ALLOWED_STATUS )} "
86- )
87-
88- if lifecycle not in ALLOWED_LIFECYCLE :
89- errors .append (
90- f"{ path } : invalid lifecycle '{ data .get ('lifecycle' )} '. Allowed: { sorted (ALLOWED_LIFECYCLE )} "
91- )
92-
93124 if severity not in ALLOWED_SEVERITY :
94125 errors .append (
95126 f"{ path } : invalid severity '{ data .get ('severity' )} '. Allowed: { sorted (ALLOWED_SEVERITY )} "
@@ -101,31 +132,45 @@ def validate_file(path: Path):
101132 elif not 0 <= risk_score <= 100 :
102133 errors .append (f"{ path } : 'risk_score' must be between 0 and 100" )
103134
104- title = str (data .get ("title" , "" )).strip ()
105- rule_id = str (data .get ("id" , "" )).strip ()
106135 query = str (data .get ("query" , "" )).strip ()
136+ if not query :
137+ errors .append (f"{ path } : 'query' must not be empty" )
107138
108- if not title :
109- errors .append (f"{ path } : 'title' must not be empty" )
139+ return errors
110140
111- if not rule_id .startswith ("SENT-" ):
112- errors .append (f"{ path } : 'id' should start with 'SENT-'" )
113141
114- if not query :
115- errors .append (f"{ path } : 'query' must not be empty" )
142+ def validate_deprecated_rule (path : Path , data : dict ):
143+ errors = []
144+
145+ for field in REQUIRED_DEPRECATED_FIELDS :
146+ if field not in data :
147+ errors .append (f"{ path } : missing required field '{ field } '" )
116148
117- relative_parts = path .relative_to (DETECTIONS_ROOT ).parts
118- if len (relative_parts ) >= 2 :
119- tactic_folder = relative_parts [0 ]
120- tags = [str (t ).strip ().lower () for t in data .get ("tags" , [])]
121- if tactic_folder not in tags :
122- errors .append (
123- f"{ path } : expected tactic folder '{ tactic_folder } ' to appear in tags"
124- )
149+ if errors :
150+ return errors
151+
152+ errors .extend (validate_common_fields (path , data ))
125153
126154 return errors
127155
128156
157+ def validate_file (path : Path ):
158+ try :
159+ data = load_yaml (path )
160+ except Exception as exc :
161+ return [f"{ path } : YAML parse error: { exc } " ], None
162+
163+ if not isinstance (data , dict ):
164+ return [f"{ path } : root YAML object must be a dictionary" ], None
165+
166+ deprecated = is_deprecated_rule (path , data )
167+
168+ if deprecated :
169+ return validate_deprecated_rule (path , data ), data
170+
171+ return validate_active_rule (path , data ), data
172+
173+
129174def main ():
130175 if not DETECTIONS_ROOT .exists ():
131176 print (f"Detection root not found: { DETECTIONS_ROOT } " )
@@ -142,21 +187,19 @@ def main():
142187 seen_titles = {}
143188
144189 for path in detection_files :
145- errors = validate_file (path )
190+ errors , data = validate_file (path )
146191 all_errors .extend (errors )
147192
148- try :
149- data = load_yaml (path )
150- if isinstance ( data , dict ) :
193+ if isinstance ( data , dict ) :
194+ deprecated = is_deprecated_rule (path , data )
195+ if not deprecated :
151196 rule_id = str (data .get ("id" , "" )).strip ()
152197 title = str (data .get ("title" , "" )).strip ().lower ()
153198
154199 if rule_id :
155200 seen_ids .setdefault (rule_id , []).append (str (path ))
156201 if title :
157202 seen_titles .setdefault (title , []).append (str (path ))
158- except Exception :
159- pass
160203
161204 for rule_id , paths in seen_ids .items ():
162205 if len (paths ) > 1 :
0 commit comments