Skip to content

Commit da33900

Browse files
committed
Harden demo timestamp and config validation
1 parent db3a2ec commit da33900

7 files changed

Lines changed: 231 additions & 9 deletions

File tree

src/telemetry_window_demo/ai_assisted_detection_demo/pipeline.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import yaml
1313

14+
from ..time_utils import parse_utc_timestamp
1415
from .llm import DemoStructuredCaseLlm
1516

1617
SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4}
@@ -1037,7 +1038,7 @@ def derive_pipeline_ts(raw_events: Sequence[Mapping[str, Any]]) -> str:
10371038

10381039

10391040
def parse_timestamp(raw_value: str) -> datetime:
1040-
return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).astimezone(UTC)
1041+
return parse_utc_timestamp(raw_value)
10411042

10421043

10431044
def format_timestamp(value: datetime) -> str:

src/telemetry_window_demo/config_change_investigation_demo/pipeline.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import yaml
1010

11+
from ..time_utils import parse_utc_timestamp
12+
1113
SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4}
1214
CHANGE_REQUIRED_FIELDS = (
1315
"change_id",
@@ -35,6 +37,11 @@
3537
"event_type",
3638
"details",
3739
)
40+
CONFIG_INPUT_PATH_FIELDS = (
41+
"config_changes",
42+
"policy_denials",
43+
"follow_on_events",
44+
)
3845

3946

4047
def 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+
122163
def 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+
309368
def 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

466529
def 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

470533
def format_timestamp(value: Any) -> str:

src/telemetry_window_demo/rule_evaluation_and_dedup_demo/pipeline.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
import yaml
1111

12+
from ..time_utils import parse_utc_timestamp
13+
1214
SCOPE_FIELDS = ("entity", "source", "target", "host")
1315
REQUIRED_HIT_FIELDS = (
1416
"hit_id",
@@ -588,7 +590,7 @@ def write_text(content: str, path: Path) -> Path:
588590

589591

590592
def parse_timestamp(raw_value: str) -> datetime:
591-
return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).astimezone(UTC)
593+
return parse_utc_timestamp(raw_value)
592594

593595

594596
def format_timestamp(value: Any) -> str:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from datetime import UTC, datetime
4+
5+
6+
def parse_utc_timestamp(raw_value: str) -> datetime:
7+
text = str(raw_value).strip()
8+
if not text:
9+
raise ValueError("Timestamp must be non-empty.")
10+
11+
try:
12+
timestamp = datetime.fromisoformat(text.replace("Z", "+00:00"))
13+
except ValueError as exc:
14+
raise ValueError(f"Invalid timestamp: {raw_value!r}") from exc
15+
16+
if timestamp.tzinfo is None:
17+
timestamp = timestamp.replace(tzinfo=UTC)
18+
return timestamp.astimezone(UTC)

tests/test_ai_assisted_detection_demo.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
load_jsonl,
2323
load_yaml,
2424
normalize_events,
25+
parse_timestamp,
2526
parse_and_validate_json_output,
2627
)
2728

@@ -109,6 +110,12 @@ def test_rules_trigger_expected_hits() -> None:
109110
assert all(hit["attack_mapping"]["technique_id"] for hit in rule_hits)
110111

111112

113+
def test_parse_timestamp_treats_naive_values_as_utc() -> None:
114+
assert parse_timestamp("2026-03-10T10:00:00").isoformat() == (
115+
"2026-03-10T10:00:00+00:00"
116+
)
117+
118+
112119
def test_grouping_merges_hits_by_entities_and_time() -> None:
113120
_, _, _, _, grouped_cases, _ = _demo_inputs()
114121

tests/test_config_change_investigation_demo.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from __future__ import annotations
22

33
import json
4+
import shutil
45
from pathlib import Path
56

7+
import pytest
8+
import yaml
9+
610
from telemetry_window_demo.config_change_investigation_demo import default_demo_root, run_demo
711
from telemetry_window_demo.config_change_investigation_demo.pipeline import (
812
build_investigations,
@@ -12,6 +16,8 @@
1216
normalize_config_changes,
1317
normalize_follow_on_events,
1418
normalize_policy_denials,
19+
parse_timestamp,
20+
validate_demo_config,
1521
)
1622

1723

@@ -34,6 +40,13 @@ def _load_json_file(path: Path):
3440
return json.loads(path.read_text(encoding="utf-8"))
3541

3642

43+
def _copy_demo_root(tmp_path: Path) -> Path:
44+
source_root = default_demo_root()
45+
target_root = tmp_path / "demo-copy"
46+
shutil.copytree(source_root, target_root)
47+
return target_root
48+
49+
3750
def test_normalize_config_changes_is_sorted_and_complete() -> None:
3851
_, _, config_changes, _, _ = _load_demo_inputs()
3952

@@ -59,6 +72,12 @@ def test_evaluate_risky_config_changes_flags_expected_changes() -> None:
5972
assert [hit["severity"] for hit in hits] == ["critical", "high", "high"]
6073

6174

75+
def test_parse_timestamp_treats_naive_values_as_utc() -> None:
76+
assert parse_timestamp("2026-03-20T09:15:00").isoformat() == (
77+
"2026-03-20T09:15:00+00:00"
78+
)
79+
80+
6281
def test_build_investigations_uses_bounded_system_and_time_correlation() -> None:
6382
_, config, config_changes, policy_denials, follow_on_events = _load_demo_inputs()
6483
hits = evaluate_risky_config_changes(config_changes, config["rules"])
@@ -86,6 +105,111 @@ def test_build_investigations_uses_bounded_system_and_time_correlation() -> None
86105
)
87106

88107

108+
def test_build_investigations_includes_evidence_on_window_end_only() -> None:
109+
change_time = parse_timestamp("2026-03-20T09:00:00Z")
110+
rule_hits = [
111+
{
112+
"investigation_id": "CCI-999",
113+
"severity": "high",
114+
"rule_id": "cfg_test",
115+
"reason": "test rule",
116+
"change_event": {
117+
"change_id": "cfg-test",
118+
"timestamp": change_time,
119+
"actor": "operator",
120+
"target_system": "identity-proxy",
121+
"config_key": "test_key",
122+
"old_value": "safe",
123+
"new_value": "risky",
124+
"change_result": "success",
125+
},
126+
}
127+
]
128+
policy_denials = [
129+
{
130+
"denial_id": "before",
131+
"timestamp": parse_timestamp("2026-03-20T08:59:59Z"),
132+
"actor": "operator",
133+
"target_system": "identity-proxy",
134+
"policy_name": "before",
135+
"decision": "deny",
136+
"reason": "too early",
137+
},
138+
{
139+
"denial_id": "on-end",
140+
"timestamp": parse_timestamp("2026-03-20T09:15:00Z"),
141+
"actor": "operator",
142+
"target_system": "identity-proxy",
143+
"policy_name": "boundary",
144+
"decision": "deny",
145+
"reason": "inside boundary",
146+
},
147+
{
148+
"denial_id": "after",
149+
"timestamp": parse_timestamp("2026-03-20T09:15:01Z"),
150+
"actor": "operator",
151+
"target_system": "identity-proxy",
152+
"policy_name": "after",
153+
"decision": "deny",
154+
"reason": "too late",
155+
},
156+
]
157+
158+
investigations = build_investigations(
159+
rule_hits,
160+
policy_denials,
161+
follow_on_events=[],
162+
correlation_minutes=15,
163+
)
164+
165+
assert [item["denial_id"] for item in investigations[0]["attached_policy_denials"]] == [
166+
"on-end"
167+
]
168+
169+
170+
def test_build_investigations_rejects_non_positive_correlation_window() -> None:
171+
with pytest.raises(ValueError, match="correlation_minutes"):
172+
build_investigations([], [], [], correlation_minutes=0)
173+
174+
175+
def test_validate_demo_config_reports_missing_input_path_key() -> None:
176+
_, config, _, _, _ = _load_demo_inputs()
177+
config["input_paths"].pop("policy_denials")
178+
179+
with pytest.raises(ValueError, match="input_paths.policy_denials"):
180+
validate_demo_config(config)
181+
182+
183+
def test_validate_demo_config_rejects_bad_correlation_window_type() -> None:
184+
_, config, _, _, _ = _load_demo_inputs()
185+
config["correlation_minutes"] = True
186+
187+
with pytest.raises(ValueError, match="correlation_minutes"):
188+
validate_demo_config(config)
189+
190+
191+
def test_validate_demo_config_rejects_missing_rules() -> None:
192+
_, config, _, _, _ = _load_demo_inputs()
193+
config["rules"] = []
194+
195+
with pytest.raises(ValueError, match="rules"):
196+
validate_demo_config(config)
197+
198+
199+
def test_run_demo_reports_config_errors_before_loading_inputs(tmp_path) -> None:
200+
demo_root = _copy_demo_root(tmp_path)
201+
config_path = demo_root / "config" / "investigation.yaml"
202+
config = load_yaml(config_path)
203+
config["input_paths"].pop("config_changes")
204+
config_path.write_text(
205+
yaml.safe_dump(config, sort_keys=False),
206+
encoding="utf-8",
207+
)
208+
209+
with pytest.raises(ValueError, match="input_paths.config_changes"):
210+
run_demo(demo_root=demo_root, artifacts_dir=tmp_path / "artifacts")
211+
212+
89213
def test_run_demo_is_deterministic_and_matches_committed_artifacts(tmp_path) -> None:
90214
demo_root, _, _, _, _ = _load_demo_inputs()
91215
first_dir = tmp_path / "run-one"

0 commit comments

Comments
 (0)