diff --git a/README.md b/README.md index 9561fa8..5e59cfa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ A local, file-based detection workflow lab for reviewer-verifiable telemetry and detection demos. -Latest milestone: [v0.6.0 — fourth demo and config-change investigation](https://github.com/stacknil/telemetry-lab/releases/latest). +Current focus: v1 reviewer contract stabilization for the five-demo matrix. + +Latest tagged release: [v0.6.0 — fourth demo and config-change investigation](https://github.com/stacknil/telemetry-lab/releases/latest). ## Reviewer Start diff --git a/demos/cloud-iam-change-investigation-demo/README.md b/demos/cloud-iam-change-investigation-demo/README.md index e47c626..a55c925 100644 --- a/demos/cloud-iam-change-investigation-demo/README.md +++ b/demos/cloud-iam-change-investigation-demo/README.md @@ -46,6 +46,16 @@ Every input record includes this CloudTrail-like skeleton: - `responseElements` - `eventID` +Optional input fields: + +- `observedTime` + +## Time Model + +- `eventTime` is normalized to `event_time` and drives sorting, bounded correlation, and signal timing. +- optional `observedTime` is preserved as `observed_time` when present, but it is not used for detection ordering. +- committed artifacts avoid `artifact_generated_at` so the demo output remains deterministic across local reruns. + AWS CloudTrail documentation describes event record contents for who made a request, the service and action, request parameters, response data, errors, source IP, user agent, Region, time, and event ID. This demo uses a synthetic subset of that shape for local review only. Reference: diff --git a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md index 37e1b5c..5d1960c 100644 --- a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md +++ b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md @@ -9,6 +9,7 @@ It uses no live AWS account, no real account IDs, no realtime ingestion, and no - normalized_events: 14 - investigation_signals: 5 - attack_mapping_count: 5 +- time_model: eventTime is normalized to event_time; optional observedTime is preserved as observed_time but not used for detection ordering ## Signals diff --git a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json index 466dda5..c0f3215 100644 --- a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json +++ b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json @@ -18,6 +18,8 @@ "evidence_events": [ { "eventID": "evt-cti-001", + "event_time": "2026-04-07T10:00:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:00:00Z", "actor": "USER_A", "eventSource": "signin.amazonaws.com", @@ -29,6 +31,8 @@ }, { "eventID": "evt-cti-002", + "event_time": "2026-04-07T10:01:20Z", + "observed_time": null, "eventTime": "2026-04-07T10:01:20Z", "actor": "USER_A", "eventSource": "signin.amazonaws.com", @@ -40,6 +44,8 @@ }, { "eventID": "evt-cti-003", + "event_time": "2026-04-07T10:03:05Z", + "observed_time": null, "eventTime": "2026-04-07T10:03:05Z", "actor": "USER_A", "eventSource": "signin.amazonaws.com", @@ -87,6 +93,8 @@ "evidence_events": [ { "eventID": "evt-cti-001", + "event_time": "2026-04-07T10:00:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:00:00Z", "actor": "USER_A", "eventSource": "signin.amazonaws.com", @@ -98,6 +106,8 @@ }, { "eventID": "evt-cti-002", + "event_time": "2026-04-07T10:01:20Z", + "observed_time": null, "eventTime": "2026-04-07T10:01:20Z", "actor": "USER_A", "eventSource": "signin.amazonaws.com", @@ -109,6 +119,8 @@ }, { "eventID": "evt-cti-003", + "event_time": "2026-04-07T10:03:05Z", + "observed_time": null, "eventTime": "2026-04-07T10:03:05Z", "actor": "USER_A", "eventSource": "signin.amazonaws.com", @@ -120,6 +132,8 @@ }, { "eventID": "evt-cti-005", + "event_time": "2026-04-07T10:05:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:05:00Z", "actor": "USER_A", "eventSource": "iam.amazonaws.com", @@ -166,6 +180,8 @@ "evidence_events": [ { "eventID": "evt-cti-006", + "event_time": "2026-04-07T10:08:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:08:00Z", "actor": "USER_A", "eventSource": "iam.amazonaws.com", @@ -209,6 +225,8 @@ "evidence_events": [ { "eventID": "evt-cti-005", + "event_time": "2026-04-07T10:05:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:05:00Z", "actor": "USER_A", "eventSource": "iam.amazonaws.com", @@ -222,6 +240,8 @@ }, { "eventID": "evt-cti-006", + "event_time": "2026-04-07T10:08:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:08:00Z", "actor": "USER_A", "eventSource": "iam.amazonaws.com", @@ -236,6 +256,8 @@ }, { "eventID": "evt-cti-007", + "event_time": "2026-04-07T10:10:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:10:00Z", "actor": "USER_A", "eventSource": "cloudtrail.amazonaws.com", @@ -284,6 +306,8 @@ "evidence_events": [ { "eventID": "evt-cti-005", + "event_time": "2026-04-07T10:05:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:05:00Z", "actor": "USER_A", "eventSource": "iam.amazonaws.com", @@ -297,6 +321,8 @@ }, { "eventID": "evt-cti-006", + "event_time": "2026-04-07T10:08:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:08:00Z", "actor": "USER_A", "eventSource": "iam.amazonaws.com", @@ -311,6 +337,8 @@ }, { "eventID": "evt-cti-008", + "event_time": "2026-04-07T10:13:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:13:00Z", "actor": "USER_A", "eventSource": "ec2.amazonaws.com", diff --git a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json index 93bb760..31d9abb 100644 --- a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json +++ b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json @@ -11,6 +11,12 @@ "security_group_ingress_opened_after_identity_change": 1 }, "attack_mapping_count": 5, + "time_model": { + "event_time_source": "eventTime", + "observed_time_source": "observedTime when present", + "detection_ordering": "event_time", + "observed_time_event_count": 0 + }, "boundaries": [ "Synthetic CloudTrail-like events only", "No live AWS account", diff --git a/demos/cloud-iam-change-investigation-demo/artifacts/normalized_cloudtrail_events.json b/demos/cloud-iam-change-investigation-demo/artifacts/normalized_cloudtrail_events.json index 321a7c9..b48f6de 100644 --- a/demos/cloud-iam-change-investigation-demo/artifacts/normalized_cloudtrail_events.json +++ b/demos/cloud-iam-change-investigation-demo/artifacts/normalized_cloudtrail_events.json @@ -1,6 +1,8 @@ [ { "eventID": "evt-cti-001", + "event_time": "2026-04-07T10:00:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:00:00Z", "actor": "USER_A", "identityType": "IAMUser", @@ -26,6 +28,8 @@ }, { "eventID": "evt-cti-002", + "event_time": "2026-04-07T10:01:20Z", + "observed_time": null, "eventTime": "2026-04-07T10:01:20Z", "actor": "USER_A", "identityType": "IAMUser", @@ -51,6 +55,8 @@ }, { "eventID": "evt-cti-003", + "event_time": "2026-04-07T10:03:05Z", + "observed_time": null, "eventTime": "2026-04-07T10:03:05Z", "actor": "USER_A", "identityType": "IAMUser", @@ -76,6 +82,8 @@ }, { "eventID": "evt-cti-004", + "event_time": "2026-04-07T10:04:10Z", + "observed_time": null, "eventTime": "2026-04-07T10:04:10Z", "actor": "USER_A", "identityType": "IAMUser", @@ -101,6 +109,8 @@ }, { "eventID": "evt-cti-005", + "event_time": "2026-04-07T10:05:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:05:00Z", "actor": "USER_A", "identityType": "IAMUser", @@ -131,6 +141,8 @@ }, { "eventID": "evt-cti-006", + "event_time": "2026-04-07T10:08:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:08:00Z", "actor": "USER_A", "identityType": "IAMUser", @@ -156,6 +168,8 @@ }, { "eventID": "evt-cti-007", + "event_time": "2026-04-07T10:10:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:10:00Z", "actor": "USER_A", "identityType": "IAMUser", @@ -180,6 +194,8 @@ }, { "eventID": "evt-cti-008", + "event_time": "2026-04-07T10:13:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:13:00Z", "actor": "USER_A", "identityType": "IAMUser", @@ -219,6 +235,8 @@ }, { "eventID": "evt-cti-009", + "event_time": "2026-04-07T10:20:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:20:00Z", "actor": "ADMIN_USER", "identityType": "IAMUser", @@ -243,6 +261,8 @@ }, { "eventID": "evt-cti-010", + "event_time": "2026-04-07T10:25:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:25:00Z", "actor": "ADMIN_USER", "identityType": "IAMUser", @@ -273,6 +293,8 @@ }, { "eventID": "evt-cti-011", + "event_time": "2026-04-07T10:30:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:30:00Z", "actor": "ADMIN_USER", "identityType": "IAMUser", @@ -298,6 +320,8 @@ }, { "eventID": "evt-cti-012", + "event_time": "2026-04-07T10:45:00Z", + "observed_time": null, "eventTime": "2026-04-07T10:45:00Z", "actor": "NETWORK_ADMIN", "identityType": "IAMUser", @@ -337,6 +361,8 @@ }, { "eventID": "evt-cti-013", + "event_time": "2026-04-07T11:00:00Z", + "observed_time": null, "eventTime": "2026-04-07T11:00:00Z", "actor": "USER_B", "identityType": "IAMUser", @@ -362,6 +388,8 @@ }, { "eventID": "evt-cti-014", + "event_time": "2026-04-07T11:30:00Z", + "observed_time": null, "eventTime": "2026-04-07T11:30:00Z", "actor": "SECURITY_AUDITOR", "identityType": "IAMUser", diff --git a/docs/event-time-model.md b/docs/event-time-model.md index 60b8797..387e792 100644 --- a/docs/event-time-model.md +++ b/docs/event-time-model.md @@ -10,8 +10,8 @@ This model is informed by the OpenTelemetry Logs Data Model distinction between | Field | Meaning | Used for detection ordering? | Current repository mapping | | --- | --- | --- | --- | -| `event_time` | Time the source event happened. | Yes | The default input column is named `timestamp`; configs may use `time.timestamp_col` to point at a source column such as `event_time`. | -| `observed_time` | Time a collector, loader, or intermediary observed the event. | No, unless a demo explicitly documents fallback behavior. | Optional future input or artifact field. Current core demos do not require it. | +| `event_time` | Time the source event happened. | Yes | The default input column is named `timestamp`; configs may use `time.timestamp_col` to point at a source column such as `event_time`. The CloudTrail-like demo normalizes source `eventTime` into `event_time`. | +| `observed_time` | Time a collector, loader, or intermediary observed the event. | No, unless a demo explicitly documents fallback behavior. | Optional input or artifact field. The CloudTrail-like demo preserves optional source `observedTime` as `observed_time` but does not use it for ordering. | | `window_start` / `window_end` | Deterministic analysis interval derived from `event_time`. | Yes | Feature rows, alert rows, and dedup artifacts use these boundaries. Windows are treated as `[window_start, window_end)`. | | `artifact_generated_at` | Time an output artifact was rendered or written. | No | Optional provenance metadata for reports, summaries, or reviewer packs. It must not be used as event evidence. | diff --git a/src/telemetry_window_demo/cloud_iam_change_investigation_demo/pipeline.py b/src/telemetry_window_demo/cloud_iam_change_investigation_demo/pipeline.py index 4ad4ab2..422706f 100644 --- a/src/telemetry_window_demo/cloud_iam_change_investigation_demo/pipeline.py +++ b/src/telemetry_window_demo/cloud_iam_change_investigation_demo/pipeline.py @@ -32,6 +32,45 @@ "security_group_ingress_opened_after_identity_change", ) SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4} +CLOUD_IAM_CONFIG_FIELDS = frozenset( + ( + "input_path", + "artifacts_dir", + "expected_source_ips", + "attack_mappings", + "rules", + ) +) +CLOUD_IAM_ATTACK_MAPPING_FIELDS = frozenset(("id", "name", "tactic", "reference")) +CLOUD_IAM_RULE_FIELDS = { + "failed_console_login_burst": frozenset( + ("name", "severity", "threshold", "window_minutes", "attack_mapping_ids") + ), + "new_access_key_creation_after_failed_logins": frozenset( + ("name", "severity", "lookback_minutes", "attack_mapping_ids") + ), + "policy_attachment_after_unusual_source_ip": frozenset( + ("name", "severity", "attack_mapping_ids") + ), + "cloudtrail_logging_disabled_near_iam_change": frozenset( + ( + "name", + "severity", + "near_window_minutes", + "identity_change_event_names", + "attack_mapping_ids", + ) + ), + "security_group_ingress_opened_after_identity_change": frozenset( + ( + "name", + "severity", + "follow_on_window_minutes", + "identity_change_event_names", + "attack_mapping_ids", + ) + ), +} def default_demo_root() -> Path: @@ -112,6 +151,8 @@ def load_jsonl(path: Path) -> list[dict[str, Any]]: def validate_demo_config(config: Mapping[str, Any]) -> dict[str, Any]: + reject_unknown_fields(config, CLOUD_IAM_CONFIG_FIELDS) + input_path = require_non_empty_string(config.get("input_path"), "input_path") artifacts_dir = require_non_empty_string( config.get("artifacts_dir", "artifacts"), @@ -135,6 +176,7 @@ def validate_demo_config(config: Mapping[str, Any]) -> dict[str, Any]: rules = config.get("rules") if not isinstance(rules, Mapping): raise ValueError("Config field 'rules' must be a mapping.") + reject_unknown_fields(rules, set(REQUIRED_RULE_IDS), parent="rules") validated_rules: dict[str, dict[str, Any]] = {} for rule_id in REQUIRED_RULE_IDS: @@ -159,6 +201,11 @@ def validate_demo_config(config: Mapping[str, Any]) -> dict[str, Any]: def validate_attack_mapping(mapping_id: object, raw_mapping: object) -> dict[str, str]: if not isinstance(raw_mapping, Mapping): raise ValueError(f"ATT&CK mapping '{mapping_id}' must be a mapping.") + reject_unknown_fields( + raw_mapping, + CLOUD_IAM_ATTACK_MAPPING_FIELDS, + parent=f"attack_mappings.{mapping_id}", + ) return { "id": require_non_empty_string(mapping_id, "attack_mappings.id"), "name": require_non_empty_string(raw_mapping.get("name"), f"attack_mappings.{mapping_id}.name"), @@ -179,6 +226,11 @@ def validate_rule_config( *, known_mapping_ids: set[str], ) -> dict[str, Any]: + reject_unknown_fields( + raw_rule, + CLOUD_IAM_RULE_FIELDS[rule_id], + parent=f"rules.{rule_id}", + ) name = require_non_empty_string(raw_rule.get("name"), f"rules.{rule_id}.name") severity = require_non_empty_string(raw_rule.get("severity"), f"rules.{rule_id}.severity") severity = severity.lower() @@ -246,6 +298,10 @@ def normalize_cloudtrail_events(raw_events: Sequence[Mapping[str, Any]]) -> list event_time = parse_timestamp( require_non_empty_string(raw_event["eventTime"], f"event {index}.eventTime") ) + observed_time = parse_optional_timestamp( + raw_event.get("observedTime"), + f"event {index}.observedTime", + ) event_source = require_non_empty_string( raw_event["eventSource"], f"event {index}.eventSource", @@ -256,6 +312,8 @@ def normalize_cloudtrail_events(raw_events: Sequence[Mapping[str, Any]]) -> list normalized.append( { "eventID": event_id, + "event_time": event_time, + "observed_time": observed_time, "eventTime": event_time, "actor": actor, "identityType": normalize_optional_text(user_identity.get("type")), @@ -280,7 +338,7 @@ def normalize_cloudtrail_events(raw_events: Sequence[Mapping[str, Any]]) -> list return sorted( normalized, - key=lambda event: (format_timestamp(event["eventTime"]), event["eventID"]), + key=lambda event: (format_timestamp(event["event_time"]), event["eventID"]), ) @@ -355,14 +413,14 @@ def detect_failed_console_login_burst( for actor, actor_events in sorted(by_actor.items()): actor_events = sorted( actor_events, - key=lambda event: (format_timestamp(event["eventTime"]), str(event["eventID"])), + key=lambda event: (format_timestamp(event["event_time"]), str(event["eventID"])), ) for index, event in enumerate(actor_events): - window_end = event["eventTime"] + window + window_end = event["event_time"] + window burst_events = [ candidate for candidate in actor_events[index:] - if candidate["eventTime"] <= window_end + if candidate["event_time"] <= window_end ] if len(burst_events) < threshold: continue @@ -371,7 +429,7 @@ def detect_failed_console_login_burst( rule_id="failed_console_login_burst", rule=rule, config=config, - signal_time=burst_events[threshold - 1]["eventTime"], + signal_time=burst_events[threshold - 1]["event_time"], actor=actor, primary_event=burst_events[threshold - 1], evidence_events=burst_events[:threshold], @@ -398,12 +456,12 @@ def detect_access_key_after_failed_logins( if not is_successful_event(event, event_source="iam.amazonaws.com", event_name="CreateAccessKey"): continue target_actor = target_identity_name(event) or str(event["actor"]) - window_start = event["eventTime"] - lookback + window_start = event["event_time"] - lookback nearby_failures = [ login for login in failed_logins if str(login["actor"]) == target_actor - and window_start <= login["eventTime"] <= event["eventTime"] + and window_start <= login["event_time"] <= event["event_time"] ] if not nearby_failures: continue @@ -412,7 +470,7 @@ def detect_access_key_after_failed_logins( rule_id="new_access_key_creation_after_failed_logins", rule=rule, config=config, - signal_time=event["eventTime"], + signal_time=event["event_time"], actor=target_actor, primary_event=event, evidence_events=[*nearby_failures, event], @@ -448,7 +506,7 @@ def detect_policy_attachment_after_unusual_source_ip( rule_id="policy_attachment_after_unusual_source_ip", rule=rule, config=config, - signal_time=event["eventTime"], + signal_time=event["event_time"], actor=str(event["actor"]), primary_event=event, evidence_events=[event], @@ -490,7 +548,7 @@ def detect_cloudtrail_logging_disabled_near_iam_change( nearby_changes = [ change for change in iam_changes - if abs(event["eventTime"] - change["eventTime"]) <= near_window + if abs(event["event_time"] - change["event_time"]) <= near_window ] if not nearby_changes: continue @@ -499,7 +557,7 @@ def detect_cloudtrail_logging_disabled_near_iam_change( rule_id="cloudtrail_logging_disabled_near_iam_change", rule=rule, config=config, - signal_time=event["eventTime"], + signal_time=event["event_time"], actor=str(event["actor"]), primary_event=event, evidence_events=[*nearby_changes, event], @@ -542,11 +600,11 @@ def detect_security_group_ingress_after_identity_change( continue if not opens_ingress_to_world(event): continue - window_start = event["eventTime"] - follow_on_window + window_start = event["event_time"] - follow_on_window nearby_changes = [ change for change in identity_changes - if window_start <= change["eventTime"] <= event["eventTime"] + if window_start <= change["event_time"] <= event["event_time"] ] if not nearby_changes: continue @@ -555,7 +613,7 @@ def detect_security_group_ingress_after_identity_change( rule_id="security_group_ingress_opened_after_identity_change", rule=rule, config=config, - signal_time=event["eventTime"], + signal_time=event["event_time"], actor=str(event["actor"]), primary_event=event, evidence_events=[*nearby_changes, event], @@ -620,6 +678,14 @@ def build_investigation_summary( "signal_count": len(signals), "rule_counts": dict(sorted(rule_counts.items())), "attack_mapping_count": len(config["attack_mappings"]), + "time_model": { + "event_time_source": "eventTime", + "observed_time_source": "observedTime when present", + "detection_ordering": "event_time", + "observed_time_event_count": sum( + 1 for event in events if event.get("observed_time") is not None + ), + }, "boundaries": [ "Synthetic CloudTrail-like events only", "No live AWS account", @@ -647,6 +713,8 @@ def build_investigation_report( f"- normalized_events: {len(events)}", f"- investigation_signals: {len(signals)}", f"- attack_mapping_count: {summary['attack_mapping_count']}", + "- time_model: eventTime is normalized to event_time; optional observedTime " + "is preserved as observed_time but not used for detection ordering", "", "## Signals", "", @@ -738,6 +806,8 @@ def opens_ingress_to_world(event: Mapping[str, Any]) -> bool: def compact_event(event: Mapping[str, Any]) -> dict[str, Any]: return { "eventID": str(event["eventID"]), + "event_time": event["event_time"], + "observed_time": event.get("observed_time"), "eventTime": event["eventTime"], "actor": str(event["actor"]), "eventSource": str(event["eventSource"]), @@ -810,6 +880,22 @@ def require_string_list(value: object, field_name: str) -> list[str]: return normalized +def reject_unknown_fields( + config: Mapping[str, Any], + allowed_fields: set[str] | frozenset[str], + *, + parent: str | None = None, +) -> None: + unknown_fields = sorted(str(key) for key in config if key not in allowed_fields) + if not unknown_fields: + return + + location = f" under '{parent}'" if parent else "" + raise ValueError( + f"Unknown config field(s){location}: " + ", ".join(unknown_fields) + ) + + def require_positive_int(value: object, field_name: str) -> int: if isinstance(value, bool): raise ValueError(f"Config field '{field_name}' must be a positive integer.") @@ -833,6 +919,16 @@ def parse_timestamp(raw_value: str) -> datetime: return parse_utc_timestamp(raw_value) +def parse_optional_timestamp(value: object, field_name: str) -> datetime | None: + raw_value = normalize_optional_text(value) + if raw_value is None: + return None + try: + return parse_timestamp(raw_value) + except ValueError as exc: + raise ValueError(f"Field '{field_name}' must be a UTC timestamp.") from exc + + def format_timestamp(value: object) -> str: timestamp = value if isinstance(value, datetime) else parse_timestamp(str(value)) return timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/tests/test_cloud_iam_change_investigation_demo.py b/tests/test_cloud_iam_change_investigation_demo.py index 4aed9a9..f6afa19 100644 --- a/tests/test_cloud_iam_change_investigation_demo.py +++ b/tests/test_cloud_iam_change_investigation_demo.py @@ -15,6 +15,7 @@ from telemetry_window_demo.cloud_iam_change_investigation_demo.pipeline import ( CLOUDTRAIL_REQUIRED_FIELDS, evaluate_cloud_iam_signals, + format_timestamp, load_jsonl, load_yaml, normalize_cloudtrail_events, @@ -67,6 +68,28 @@ def test_normalize_cloudtrail_events_is_sorted_and_derives_actor() -> None: assert normalized_events[4]["outcome"] == "success" +def test_normalize_cloudtrail_events_uses_event_time_not_observed_time_for_ordering() -> None: + _, _, raw_events, _, _ = _demo_inputs() + event_a = { + **raw_events[0], + "eventID": "evt-time-a", + "eventTime": "2026-04-07T10:05:00Z", + "observedTime": "2026-04-07T10:00:00Z", + } + event_b = { + **raw_events[1], + "eventID": "evt-time-b", + "eventTime": "2026-04-07T10:01:00Z", + "observedTime": "2026-04-07T10:30:00Z", + } + + normalized = normalize_cloudtrail_events([event_a, event_b]) + + assert [event["eventID"] for event in normalized] == ["evt-time-b", "evt-time-a"] + assert format_timestamp(normalized[0]["event_time"]) == "2026-04-07T10:01:00Z" + assert format_timestamp(normalized[0]["observed_time"]) == "2026-04-07T10:30:00Z" + + def test_evaluate_cloud_iam_signals_flags_expected_rules() -> None: _, _, _, _, signals = _demo_inputs() @@ -172,6 +195,35 @@ def test_validate_demo_config_rejects_more_than_five_attack_mappings() -> None: validate_demo_config(config) +@pytest.mark.parametrize( + ("mutator", "expected_error"), + [ + ( + lambda config: config.update({"unused": True}), + "Unknown config field", + ), + ( + lambda config: config["rules"]["failed_console_login_burst"].update( + {"typo_threshold": 3} + ), + "rules.failed_console_login_burst", + ), + ( + lambda config: config["attack_mappings"]["T1078.004"].update( + {"platform": "cloud"} + ), + "attack_mappings.T1078.004", + ), + ], +) +def test_validate_demo_config_rejects_unknown_fields(mutator, expected_error) -> None: + _, config, _, _, _ = _demo_inputs() + mutator(config) + + with pytest.raises(ValueError, match=expected_error): + validate_demo_config(config) + + def test_normalize_cloudtrail_events_reports_missing_required_field() -> None: _, _, raw_events, _, _ = _demo_inputs() broken_event = dict(raw_events[0]) @@ -193,6 +245,13 @@ def test_run_demo_is_deterministic_and_matches_committed_artifacts(tmp_path) -> assert first_result["rule_count"] == 5 assert first_result["signal_count"] == 5 assert second_result["signal_count"] == first_result["signal_count"] + generated_summary = _load_json_file(first_dir / "investigation_summary.json") + assert generated_summary["time_model"] == { + "event_time_source": "eventTime", + "observed_time_source": "observedTime when present", + "detection_ordering": "event_time", + "observed_time_event_count": 0, + } for name in ( "normalized_cloudtrail_events.json", diff --git a/tests/test_event_time_model_docs.py b/tests/test_event_time_model_docs.py index ce83d8a..fe0cc0b 100644 --- a/tests/test_event_time_model_docs.py +++ b/tests/test_event_time_model_docs.py @@ -27,6 +27,8 @@ def test_event_time_model_documents_time_field_boundaries() -> None: assert "OpenTelemetry Logs Data Model" in doc assert "Timestamp" in doc assert "ObservedTimestamp" in doc + assert "source `eventTime` into `event_time`" in doc + assert "source `observedTime` as `observed_time`" in doc assert "[window_start, window_end)" in doc assert "must not be used as event evidence" in doc assert "(docs/event-time-model.md)" in readme