88
99import yaml
1010
11+ from ..time_utils import parse_utc_timestamp
12+
1113SEVERITY_ORDER = {"low" : 1 , "medium" : 2 , "high" : 3 , "critical" : 4 }
1214CHANGE_REQUIRED_FIELDS = (
1315 "change_id" ,
3537 "event_type" ,
3638 "details" ,
3739)
40+ CONFIG_INPUT_PATH_FIELDS = (
41+ "config_changes" ,
42+ "policy_denials" ,
43+ "follow_on_events" ,
44+ )
3845
3946
4047def default_demo_root () -> Path :
@@ -46,13 +53,14 @@ def run_demo(
4653 artifacts_dir : Path | None = None ,
4754) -> dict [str , Any ]:
4855 demo_root = Path (demo_root or default_demo_root ()).resolve ()
49- config = load_yaml (demo_root / "config" / "investigation.yaml" )
50- input_paths = config . get ( "input_paths" , {})
56+ config = validate_demo_config ( load_yaml (demo_root / "config" / "investigation.yaml" ) )
57+ input_paths = config [ "input_paths" ]
5158 artifacts_dir = Path (
5259 artifacts_dir
53- or resolve_demo_path (demo_root , str (config . get ( "artifacts_dir" , "artifacts" ) ))
60+ or resolve_demo_path (demo_root , str (config [ "artifacts_dir" ] ))
5461 ).resolve ()
5562 artifacts_dir .mkdir (parents = True , exist_ok = True )
63+ correlation_minutes = int (config ["correlation_minutes" ])
5664
5765 config_changes = normalize_config_changes (
5866 load_jsonl (resolve_demo_path (demo_root , str (input_paths ["config_changes" ])))
@@ -69,17 +77,17 @@ def run_demo(
6977 rule_hits ,
7078 policy_denials ,
7179 follow_on_events ,
72- correlation_minutes = int ( config . get ( " correlation_minutes" , 15 )) ,
80+ correlation_minutes = correlation_minutes ,
7381 )
7482 summary = build_investigation_summary (
7583 investigations ,
76- correlation_minutes = int ( config . get ( " correlation_minutes" , 15 )) ,
84+ correlation_minutes = correlation_minutes ,
7785 )
7886 report_text = build_investigation_report (
7987 config_changes = config_changes ,
8088 rule_hits = rule_hits ,
8189 investigations = investigations ,
82- correlation_minutes = int ( config . get ( " correlation_minutes" , 15 )) ,
90+ correlation_minutes = correlation_minutes ,
8391 )
8492
8593 paths = {
@@ -119,6 +127,39 @@ def load_yaml(path: Path) -> dict[str, Any]:
119127 return payload
120128
121129
130+ def validate_demo_config (config : Mapping [str , Any ]) -> dict [str , Any ]:
131+ input_paths = config .get ("input_paths" )
132+ if not isinstance (input_paths , Mapping ):
133+ raise ValueError ("Config field 'input_paths' must be a mapping." )
134+
135+ validated_input_paths : dict [str , str ] = {}
136+ for field in CONFIG_INPUT_PATH_FIELDS :
137+ validated_input_paths [field ] = require_non_empty_string (
138+ input_paths .get (field ),
139+ f"input_paths.{ field } " ,
140+ )
141+
142+ artifacts_dir = require_non_empty_string (
143+ config .get ("artifacts_dir" , "artifacts" ),
144+ "artifacts_dir" ,
145+ )
146+ correlation_minutes = require_positive_int (
147+ config .get ("correlation_minutes" , 15 ),
148+ "correlation_minutes" ,
149+ )
150+
151+ rules = config .get ("rules" )
152+ if not isinstance (rules , list ) or not rules :
153+ raise ValueError ("Config field 'rules' must be a non-empty list." )
154+
155+ return {
156+ "input_paths" : validated_input_paths ,
157+ "artifacts_dir" : artifacts_dir ,
158+ "correlation_minutes" : correlation_minutes ,
159+ "rules" : rules ,
160+ }
161+
162+
122163def load_jsonl (path : Path ) -> list [dict [str , Any ]]:
123164 records : list [dict [str , Any ]] = []
124165 with path .open ("r" , encoding = "utf-8" ) as handle :
@@ -306,13 +347,35 @@ def validate_rules(rules: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
306347 return validated
307348
308349
350+ def require_positive_int (value : Any , field_name : str ) -> int :
351+ if isinstance (value , bool ):
352+ raise ValueError (f"{ field_name } must be a positive integer." )
353+ try :
354+ parsed = int (value )
355+ except (TypeError , ValueError ) as exc :
356+ raise ValueError (f"{ field_name } must be a positive integer." ) from exc
357+ if parsed <= 0 :
358+ raise ValueError (f"{ field_name } must be a positive integer." )
359+ return parsed
360+
361+
362+ def require_non_empty_string (value : Any , field_name : str ) -> str :
363+ if not isinstance (value , str ) or not value .strip ():
364+ raise ValueError (f"Config field '{ field_name } ' must be a non-empty string." )
365+ return value .strip ()
366+
367+
309368def build_investigations (
310369 rule_hits : Sequence [Mapping [str , Any ]],
311370 policy_denials : Sequence [Mapping [str , Any ]],
312371 follow_on_events : Sequence [Mapping [str , Any ]],
313372 correlation_minutes : int ,
314373) -> list [dict [str , Any ]]:
315374 investigations : list [dict [str , Any ]] = []
375+ correlation_minutes = require_positive_int (
376+ correlation_minutes ,
377+ "correlation_minutes" ,
378+ )
316379 correlation_window = timedelta (minutes = correlation_minutes )
317380
318381 for hit in rule_hits :
@@ -464,7 +527,7 @@ def normalize_optional_text(value: Any) -> str | None:
464527
465528
466529def parse_timestamp (raw_value : str ) -> datetime :
467- return datetime . fromisoformat (raw_value . replace ( "Z" , "+00:00" )). astimezone ( UTC )
530+ return parse_utc_timestamp (raw_value )
468531
469532
470533def format_timestamp (value : Any ) -> str :
0 commit comments