Skip to content

Commit ca29200

Browse files
committed
Harden run config validation
1 parent 55a0614 commit ca29200

2 files changed

Lines changed: 262 additions & 31 deletions

File tree

src/telemetry_window_demo/cli.py

Lines changed: 171 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

3-
import argparse
4-
from pathlib import Path
5-
from typing import Any
3+
import argparse
4+
from collections.abc import Mapping, Sequence
5+
from pathlib import Path
6+
from typing import Any
67

78
from .features import compute_window_features
89
from .io import (
@@ -16,12 +17,21 @@
1617
write_table,
1718
)
1819
from .preprocess import normalize_events
19-
from .rules import apply_rules
20-
from .visualize import plot_outputs
21-
from .windowing import build_windows
22-
23-
24-
def main() -> None:
20+
from .rules import apply_rules
21+
from .visualize import plot_outputs
22+
from .windowing import build_windows
23+
24+
RUN_RULE_SECTION_NAMES = (
25+
"high_error_rate",
26+
"login_fail_burst",
27+
"high_severity_spike",
28+
"persistent_high_error",
29+
"source_spread_spike",
30+
"rare_event_repeat",
31+
)
32+
33+
34+
def main() -> None:
2535
parser = build_parser()
2636
args = parser.parse_args()
2737
args.func(args)
@@ -88,14 +98,15 @@ def build_parser() -> argparse.ArgumentParser:
8898
return parser
8999

90100

91-
def run_command(args: argparse.Namespace) -> None:
92-
config_path = Path(args.config).resolve()
93-
config = load_config(config_path)
94-
time_config = config.get("time", {})
95-
feature_config = config.get("features", {})
96-
rules_config = config.get("rules") or {}
97-
input_path = resolve_config_path(config_path, config["input_path"])
98-
output_dir = resolve_config_path(config_path, config.get("output_dir", "data/processed"))
101+
def run_command(args: argparse.Namespace) -> None:
102+
config_path = Path(args.config).resolve()
103+
config = load_config(config_path)
104+
run_config = _validate_run_config(config)
105+
time_config = run_config["time"]
106+
feature_config = run_config["features"]
107+
rules_config = run_config["rules"]
108+
input_path = resolve_config_path(config_path, run_config["input_path"])
109+
output_dir = resolve_config_path(config_path, run_config["output_dir"])
99110

100111
events = load_events(input_path)
101112
normalized = normalize_events(
@@ -104,19 +115,19 @@ def run_command(args: argparse.Namespace) -> None:
104115
error_statuses=feature_config.get("error_statuses"),
105116
high_severity_levels=feature_config.get("severity_levels"),
106117
)
107-
windows = build_windows(
108-
normalized,
109-
timestamp_col=time_config.get("timestamp_col", "timestamp"),
110-
window_size_seconds=int(time_config.get("window_size_seconds", 60)),
111-
step_size_seconds=int(time_config.get("step_size_seconds", 10)),
112-
)
118+
windows = build_windows(
119+
normalized,
120+
timestamp_col=time_config.get("timestamp_col", "timestamp"),
121+
window_size_seconds=time_config["window_size_seconds"],
122+
step_size_seconds=time_config["step_size_seconds"],
123+
)
113124
features = compute_window_features(
114125
normalized,
115126
windows,
116127
count_event_types=feature_config.get("count_event_types"),
117-
)
118-
alerts = apply_rules(features, rules_config)
119-
cooldown_seconds = int(rules_config.get("cooldown_seconds", 0))
128+
)
129+
alerts = apply_rules(features, rules_config)
130+
cooldown_seconds = rules_config["cooldown_seconds"]
120131

121132
feature_path = write_table(features, output_dir / "features.csv")
122133
alert_path = write_table(alerts, output_dir / "alerts.csv")
@@ -222,16 +233,145 @@ def run_config_change_demo_command(args: argparse.Namespace) -> None:
222233
print(f" - {name}: {_display_path(path)}")
223234

224235

225-
def _display_path(path: Path) -> str:
236+
def _display_path(path: Path) -> str:
226237
cwd = Path.cwd().resolve()
227238
resolved = path.resolve()
228239
try:
229240
return resolved.relative_to(cwd).as_posix()
230241
except ValueError:
231-
return resolved.as_posix()
232-
233-
234-
def _build_run_summary(
242+
return resolved.as_posix()
243+
244+
245+
def _validate_run_config(config: Mapping[str, Any]) -> dict[str, Any]:
246+
time_config = _optional_mapping(config.get("time", {}), "time")
247+
feature_config = _optional_mapping(config.get("features", {}), "features")
248+
rules_config = _validate_rules_config(config.get("rules"))
249+
250+
return {
251+
"input_path": _path_config_value(config.get("input_path"), "input_path"),
252+
"output_dir": _path_config_value(
253+
config.get("output_dir", "data/processed"),
254+
"output_dir",
255+
),
256+
"time": {
257+
"timestamp_col": _string_config_value(
258+
time_config.get("timestamp_col", "timestamp"),
259+
"time.timestamp_col",
260+
),
261+
"window_size_seconds": _int_config_value(
262+
time_config.get("window_size_seconds", 60),
263+
"time.window_size_seconds",
264+
minimum=1,
265+
),
266+
"step_size_seconds": _int_config_value(
267+
time_config.get("step_size_seconds", 10),
268+
"time.step_size_seconds",
269+
minimum=1,
270+
),
271+
},
272+
"features": {
273+
"count_event_types": _optional_string_sequence(
274+
feature_config.get("count_event_types"),
275+
"features.count_event_types",
276+
),
277+
"error_statuses": _optional_string_sequence(
278+
feature_config.get("error_statuses"),
279+
"features.error_statuses",
280+
),
281+
"severity_levels": _optional_string_sequence(
282+
feature_config.get("severity_levels"),
283+
"features.severity_levels",
284+
),
285+
},
286+
"rules": rules_config,
287+
}
288+
289+
290+
def _validate_rules_config(raw_rules_config: Any) -> dict[str, Any]:
291+
rules_config = (
292+
{}
293+
if raw_rules_config is None
294+
else dict(_optional_mapping(raw_rules_config, "rules"))
295+
)
296+
rules_config["cooldown_seconds"] = _int_config_value(
297+
rules_config.get("cooldown_seconds", 0),
298+
"rules.cooldown_seconds",
299+
minimum=0,
300+
)
301+
302+
for rule_name in RUN_RULE_SECTION_NAMES:
303+
if rule_name in rules_config:
304+
rules_config[rule_name] = dict(
305+
_optional_mapping(rules_config[rule_name], f"rules.{rule_name}")
306+
)
307+
308+
rare_event_repeat = rules_config.get("rare_event_repeat")
309+
if isinstance(rare_event_repeat, dict) and "event_types" in rare_event_repeat:
310+
rare_event_repeat["event_types"] = _string_sequence(
311+
rare_event_repeat["event_types"],
312+
"rules.rare_event_repeat.event_types",
313+
)
314+
315+
return rules_config
316+
317+
318+
def _optional_mapping(value: Any, field_name: str) -> Mapping[str, Any]:
319+
if not isinstance(value, Mapping):
320+
raise ValueError(f"Config field '{field_name}' must be a mapping.")
321+
return value
322+
323+
324+
def _path_config_value(value: Any, field_name: str) -> str:
325+
if not isinstance(value, str) or not value.strip():
326+
raise ValueError(f"Config field '{field_name}' must be a non-empty path string.")
327+
return value.strip()
328+
329+
330+
def _string_config_value(value: Any, field_name: str) -> str:
331+
if not isinstance(value, str) or not value.strip():
332+
raise ValueError(f"Config field '{field_name}' must be a non-empty string.")
333+
return value.strip()
334+
335+
336+
def _int_config_value(value: Any, field_name: str, *, minimum: int) -> int:
337+
if isinstance(value, bool):
338+
raise ValueError(f"Config field '{field_name}' must be an integer.")
339+
if isinstance(value, int):
340+
parsed = value
341+
elif isinstance(value, str) and value.strip().lstrip("+-").isdigit():
342+
parsed = int(value)
343+
else:
344+
raise ValueError(f"Config field '{field_name}' must be an integer.")
345+
346+
if parsed < minimum:
347+
qualifier = "positive" if minimum == 1 else f"at least {minimum}"
348+
raise ValueError(f"Config field '{field_name}' must be {qualifier}.")
349+
return parsed
350+
351+
352+
def _optional_string_sequence(value: Any, field_name: str) -> list[str] | None:
353+
if value is None:
354+
return None
355+
return _string_sequence(value, field_name)
356+
357+
358+
def _string_sequence(value: Any, field_name: str) -> list[str]:
359+
if isinstance(value, str) or not isinstance(value, Sequence):
360+
raise ValueError(
361+
f"Config field '{field_name}' must be a list of non-empty strings."
362+
)
363+
364+
normalized: list[str] = []
365+
for item in value:
366+
if not isinstance(item, str) or not item.strip():
367+
raise ValueError(
368+
f"Config field '{field_name}' must be a list of non-empty strings."
369+
)
370+
normalized.append(item.strip())
371+
return normalized
372+
373+
374+
def _build_run_summary(
235375
input_path: Path,
236376
output_dir: Path,
237377
normalized: Any,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from __future__ import annotations
2+
3+
from argparse import Namespace
4+
from pathlib import Path
5+
from typing import Any
6+
7+
import pytest
8+
import yaml
9+
10+
from telemetry_window_demo.cli import run_command
11+
from telemetry_window_demo.io import load_config
12+
13+
14+
def _base_config(tmp_path: Path) -> dict[str, Any]:
15+
repo_root = Path(__file__).resolve().parents[1]
16+
config = load_config(repo_root / "configs" / "default.yaml")
17+
config["input_path"] = str((repo_root / "data" / "raw" / "sample_events.jsonl").resolve())
18+
config["output_dir"] = str((tmp_path / "processed").resolve())
19+
return config
20+
21+
22+
def _write_config(tmp_path: Path, config: dict[str, Any]) -> Path:
23+
config_path = tmp_path / "invalid.yaml"
24+
config_path.write_text(
25+
yaml.safe_dump(config, sort_keys=False),
26+
encoding="utf-8",
27+
)
28+
return config_path
29+
30+
31+
def test_run_config_requires_input_path(tmp_path) -> None:
32+
config = _base_config(tmp_path)
33+
del config["input_path"]
34+
config_path = _write_config(tmp_path, config)
35+
36+
with pytest.raises(ValueError, match="input_path"):
37+
run_command(Namespace(config=str(config_path)))
38+
39+
40+
def test_run_config_rejects_boolean_window_size(tmp_path) -> None:
41+
config = _base_config(tmp_path)
42+
config["time"]["window_size_seconds"] = True
43+
config_path = _write_config(tmp_path, config)
44+
45+
with pytest.raises(ValueError, match="time.window_size_seconds"):
46+
run_command(Namespace(config=str(config_path)))
47+
48+
49+
def test_run_config_rejects_non_positive_step_size(tmp_path) -> None:
50+
config = _base_config(tmp_path)
51+
config["time"]["step_size_seconds"] = 0
52+
config_path = _write_config(tmp_path, config)
53+
54+
with pytest.raises(ValueError, match="time.step_size_seconds"):
55+
run_command(Namespace(config=str(config_path)))
56+
57+
58+
def test_run_config_rejects_string_feature_list(tmp_path) -> None:
59+
config = _base_config(tmp_path)
60+
config["features"]["count_event_types"] = "login_fail"
61+
config_path = _write_config(tmp_path, config)
62+
63+
with pytest.raises(ValueError, match="features.count_event_types"):
64+
run_command(Namespace(config=str(config_path)))
65+
66+
67+
def test_run_config_rejects_boolean_cooldown(tmp_path) -> None:
68+
config = _base_config(tmp_path)
69+
config["rules"]["cooldown_seconds"] = True
70+
config_path = _write_config(tmp_path, config)
71+
72+
with pytest.raises(ValueError, match="rules.cooldown_seconds"):
73+
run_command(Namespace(config=str(config_path)))
74+
75+
76+
def test_run_config_rejects_non_mapping_rule_section(tmp_path) -> None:
77+
config = _base_config(tmp_path)
78+
config["rules"]["high_error_rate"] = True
79+
config_path = _write_config(tmp_path, config)
80+
81+
with pytest.raises(ValueError, match="rules.high_error_rate"):
82+
run_command(Namespace(config=str(config_path)))
83+
84+
85+
def test_run_config_rejects_string_rare_event_types(tmp_path) -> None:
86+
config = _base_config(tmp_path)
87+
config["rules"]["rare_event_repeat"]["event_types"] = "malware_alert"
88+
config_path = _write_config(tmp_path, config)
89+
90+
with pytest.raises(ValueError, match="rules.rare_event_repeat.event_types"):
91+
run_command(Namespace(config=str(config_path)))

0 commit comments

Comments
 (0)