From 76133bf1ca872af1cde182303f486f0edc9b011f Mon Sep 17 00:00:00 2001 From: stacknil Date: Sun, 14 Jun 2026 20:34:02 +0800 Subject: [PATCH] feat(demo): add synthetic CloudTrail IAM investigation --- README.md | 17 +- .../README.md | 108 +++ .../artifacts/investigation_report.md | 71 ++ .../artifacts/investigation_signals.json | 356 +++++++ .../artifacts/investigation_summary.json | 21 + .../normalized_cloudtrail_events.json | 387 ++++++++ .../config/investigation.yaml | 69 ++ .../synthetic_cloudtrail_like_events.jsonl | 14 + docs/architecture.md | 6 +- docs/reviewer-brief.md | 6 +- docs/reviewer-pack.md | 5 +- docs/reviewer-path.md | 2 + docs/roadmap.md | 14 +- src/telemetry_window_demo/cli.py | 24 + .../__init__.py | 5 + .../pipeline.py | 865 ++++++++++++++++++ tests/test_cli_errors.py | 7 +- tests/test_cli_subprocess.py | 29 + ...est_cloud_iam_change_investigation_demo.py | 234 +++++ tests/test_reviewer_docs.py | 14 +- 20 files changed, 2235 insertions(+), 19 deletions(-) create mode 100644 demos/cloud-iam-change-investigation-demo/README.md create mode 100644 demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md create mode 100644 demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json create mode 100644 demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json create mode 100644 demos/cloud-iam-change-investigation-demo/artifacts/normalized_cloudtrail_events.json create mode 100644 demos/cloud-iam-change-investigation-demo/config/investigation.yaml create mode 100644 demos/cloud-iam-change-investigation-demo/data/raw/synthetic_cloudtrail_like_events.jsonl create mode 100644 src/telemetry_window_demo/cloud_iam_change_investigation_demo/__init__.py create mode 100644 src/telemetry_window_demo/cloud_iam_change_investigation_demo/pipeline.py create mode 100644 tests/test_cloud_iam_change_investigation_demo.py diff --git a/README.md b/README.md index 7039656..356aab8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Latest milestone: [v0.6.0 — fourth demo and config-change investigation](https - [ai-assisted-detection-demo](demos/ai-assisted-detection-demo/README.md) - [rule-evaluation-and-dedup-demo](demos/rule-evaluation-and-dedup-demo/README.md) - [config-change-investigation-demo](demos/config-change-investigation-demo/README.md) +- [cloud-iam-change-investigation-demo](demos/cloud-iam-change-investigation-demo/README.md) | Demo | Input | Deterministic core | LLM role | Main artifacts | Guardrails / non-goals | | --- | --- | --- | --- | --- | --- | @@ -26,10 +27,11 @@ Latest milestone: [v0.6.0 — fourth demo and config-change investigation](https | [ai-assisted-detection-demo](demos/ai-assisted-detection-demo/README.md) | JSONL auth / web / process | Normalize
Rules
Grouping
ATT&CK mapping | JSON-only case drafting | `rule_hits.json`
`case_bundles.json`
`case_summaries.json`
`case_report.md`
`audit_traces.jsonl` | Human verification required
No autonomous response
No final verdict | | [rule-evaluation-and-dedup-demo](demos/rule-evaluation-and-dedup-demo/README.md) | JSON raw rule hits | Scope resolution
Cooldown grouping
Suppression reasoning | None | `rule_hits_before_dedup.json`
`rule_hits_after_dedup.json`
`dedup_explanations.json`
`dedup_report.md` | No realtime
No dashboard
No AI stage | | [config-change-investigation-demo](demos/config-change-investigation-demo/README.md) | JSONL config changes
Policy denials
Follow-on events | Normalize
Risky-change rules
Bounded correlation | None | `change_events_normalized.json`
`investigation_hits.json`
`investigation_summary.json`
`investigation_report.md` | No realtime
No dashboard
No AI stage | +| [cloud-iam-change-investigation-demo](demos/cloud-iam-change-investigation-demo/README.md) | Synthetic CloudTrail-like JSONL | Validate
IAM rules
Bounded correlation
ATT&CK mapping | None | `normalized_cloudtrail_events.json`
`investigation_signals.json`
`investigation_summary.json`
`investigation_report.md` | Synthetic only
No live AWS
No final verdict | ## What This Repo Is -`telemetry-lab` is a small portfolio repository for constrained detection workflows. It is not a SIEM, dashboard, or monitoring platform; it is organized as four local, file-based demos that are reproducible from committed sample data and intentionally scoped for public review rather than production use. +`telemetry-lab` is a small portfolio repository for constrained detection workflows. It is not a SIEM, dashboard, or monitoring platform; it is organized as five local, file-based demos that are reproducible from committed sample data and intentionally scoped for public review rather than production use. ### telemetry-window-demo @@ -46,6 +48,10 @@ Latest milestone: [v0.6.0 — fourth demo and config-change investigation](https ### config-change-investigation-demo `config-change-investigation-demo` follows risky configuration changes into bounded follow-on evidence such as policy denials and service signals. It stays deterministic, file-based, and review-oriented, with no added AI stage. + +### cloud-iam-change-investigation-demo + +`cloud-iam-change-investigation-demo` uses synthetic CloudTrail-like events to review IAM changes, failed console logins, CloudTrail logging changes, and security group ingress changes with bounded deterministic rules. It has no live AWS account, no real account ID, no production detection claim, and no final incident verdict. ## Quick Run @@ -62,6 +68,7 @@ Other demo entrypoints: - `python -m telemetry_window_demo.cli run-ai-demo` - `python -m telemetry_window_demo.cli run-rule-dedup-demo` - `python -m telemetry_window_demo.cli run-config-change-demo` +- `python -m telemetry_window_demo.cli run-cloud-iam-change-demo` Useful inspection commands: @@ -99,7 +106,8 @@ For a quick coherence pass across the demos: 1. Run `python -m telemetry_window_demo.cli run --config configs/default.yaml` and confirm `data/processed/summary.json` reports `41` events, `24` windows, and `12` alerts. 2. Run `python -m telemetry_window_demo.cli run-rule-dedup-demo` and confirm `demos/rule-evaluation-and-dedup-demo/artifacts/dedup_report.md` shows `10` raw hits reduced to `6` retained alerts with `4` suppressions. 3. Run `python -m telemetry_window_demo.cli run-config-change-demo` and confirm `demos/config-change-investigation-demo/artifacts/investigation_report.md` shows `4` normalized changes, `3` risky changes, and `3` investigations. -4. Run `python -m telemetry_window_demo.cli run-ai-demo` and confirm `demos/ai-assisted-detection-demo/artifacts/case_report.md` shows `3` deterministic cases with human verification and no final incident verdict. +4. Run `python -m telemetry_window_demo.cli run-cloud-iam-change-demo` and confirm `demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md` shows `14` CloudTrail-like events and `5` investigation signals. +5. Run `python -m telemetry_window_demo.cli run-ai-demo` and confirm `demos/ai-assisted-detection-demo/artifacts/case_report.md` shows `3` deterministic cases with human verification and no final incident verdict. ## Demo Variants @@ -150,6 +158,7 @@ Cooldown behavior: - [`demos/rule-evaluation-and-dedup-demo/README.md`](demos/rule-evaluation-and-dedup-demo/README.md) explains the third demo and links its committed before/after dedup artifacts - [`demos/config-change-investigation-demo/README.md`](demos/config-change-investigation-demo/README.md) explains the config-change investigation demo and its committed artifacts +- [`demos/cloud-iam-change-investigation-demo/README.md`](demos/cloud-iam-change-investigation-demo/README.md) explains the synthetic CloudTrail-like IAM investigation demo and its committed artifacts - [`docs/README.md`](docs/README.md) indexes current reviewer docs, supporting design notes, and historical release evidence - [`docs/reviewer-pack.md`](docs/reviewer-pack.md) is the top-level no-guessing reviewer pack and artifact naming contract - [`docs/reviewer-brief.md`](docs/reviewer-brief.md) gives the short problem, value, evidence, and boundary summary @@ -164,11 +173,11 @@ Cooldown behavior: ## v0.7 / v1.0 Direction -- stabilize the four-demo matrix and avoid broad platform expansion +- stabilize the five-demo matrix and avoid broad platform expansion - freeze reviewer-visible artifact names unless a rename is intentionally coordinated across docs, tests, and sample outputs - use [`docs/reviewer-pack.md`](docs/reviewer-pack.md) and [`docs/architecture.md`](docs/architecture.md) as the consolidation entrypoints - use the [`v1 readiness gate`](docs/reviewer-pack.md#v1-readiness-gate) before treating the repo as consolidated -- add at most one more demo before v1-style consolidation +- avoid additional demo expansion before v1-style consolidation ## Scope diff --git a/demos/cloud-iam-change-investigation-demo/README.md b/demos/cloud-iam-change-investigation-demo/README.md new file mode 100644 index 0000000..e47c626 --- /dev/null +++ b/demos/cloud-iam-change-investigation-demo/README.md @@ -0,0 +1,108 @@ +# Cloud IAM Change Investigation Demo + +This demo is part of `telemetry-lab` and stays intentionally small, local, and reviewer-friendly. + +It uses synthetic CloudTrail-like events to show bounded investigation logic around IAM and nearby cloud-control-plane changes. It does not connect to AWS and does not produce a final incident verdict. + +## Purpose + +The goal is to make one compact CloudTrail-style investigation path legible from committed sample data. + +The demo starts from one JSONL file, then: + +- validates a CloudTrail-like event skeleton +- normalizes events into deterministic internal records +- applies five bounded investigation rules +- attaches a small ATT&CK mapping set for reviewer orientation +- writes machine-readable summaries and a short reviewer-facing report + +## Quick Start + +From the repository root: + +```bash +python -m pip install -e . +python -m telemetry_window_demo.cli run-cloud-iam-change-demo +``` + +Generated artifacts are written to `demos/cloud-iam-change-investigation-demo/artifacts/`. + +## Demo Input + +- events: `data/raw/synthetic_cloudtrail_like_events.jsonl` +- investigation config: `config/investigation.yaml` + +Every input record includes this CloudTrail-like skeleton: + +- `eventTime` +- `userIdentity` +- `eventSource` +- `eventName` +- `awsRegion` +- `sourceIPAddress` +- `userAgent` +- `errorCode` +- `requestParameters` +- `responseElements` +- `eventID` + +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: + +- [AWS CloudTrail record contents](https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-event-reference-record-contents.html) + +## Rules + +The deterministic rules are: + +- failed console login burst +- new access key creation after failed logins +- policy attachment after unusual source IP +- CloudTrail logging disabled near IAM change +- security group ingress opened after identity change + +## ATT&CK Mapping + +The config intentionally keeps the mapping set small: + +- [Valid Accounts: Cloud Accounts](https://attack.mitre.org/techniques/T1078/004/) +- [Brute Force: Password Spraying](https://attack.mitre.org/techniques/T1110/003/) +- [Account Manipulation: Additional Cloud Credentials](https://attack.mitre.org/techniques/T1098/001/) +- [Disable or Modify Cloud Log](https://attack.mitre.org/techniques/T1685/002/) +- [Modify Cloud Compute Infrastructure](https://attack.mitre.org/techniques/T1578/) + +These mappings are reviewer context, not a verdict. + +## Expected Artifacts + +- `artifacts/normalized_cloudtrail_events.json` +- `artifacts/investigation_signals.json` +- `artifacts/investigation_summary.json` +- `artifacts/investigation_report.md` + +## Expected Run Summary + +The bundled sample run should report: + +- `14` normalized CloudTrail-like events +- `5` evaluated investigation rules +- `5` investigation signals +- `5` ATT&CK mapping entries + +## Reviewer Walkthrough + +1. Open `synthetic_cloudtrail_like_events.jsonl` and verify the CloudTrail-like fields are synthetic placeholders. +2. Open `normalized_cloudtrail_events.json` and confirm the sample was normalized without adding external context. +3. Open `investigation_signals.json` and inspect which event IDs each bounded rule attached. +4. Open `investigation_summary.json` and confirm the boundaries remain explicit. +5. Open `investigation_report.md` and verify the report stays reviewer-facing, not incident-final. + +## Boundaries + +- synthetic CloudTrail-like events only +- no live AWS account +- no real account ID +- no production detection claim +- no final incident verdict +- no SIEM, dashboard, alert routing, case-management, realtime ingestion, or autonomous response diff --git a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md new file mode 100644 index 0000000..37e1b5c --- /dev/null +++ b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md @@ -0,0 +1,71 @@ +# Cloud IAM Change Investigation Demo Report + +This deterministic demo reviews synthetic CloudTrail-like events for bounded IAM and cloud-control-plane signals. +It uses no live AWS account, no real account IDs, no realtime ingestion, and no final incident verdict. + +## Run Summary + +- source_type: synthetic CloudTrail-like JSONL +- normalized_events: 14 +- investigation_signals: 5 +- attack_mapping_count: 5 + +## Signals + +### CTI-001 - failed console login burst + +- Severity: medium +- Actor: USER_A +- Primary event: evt-cti-003 +- Evidence event IDs: evt-cti-001, evt-cti-002, evt-cti-003 +- ATT&CK mapping: Brute Force: Password Spraying, Valid Accounts: Cloud Accounts +- Bounded reason: 3 failed ConsoleLogin events for USER_A fell inside a 5 minute window. +- Scope: synthetic reviewer signal only; no production claim or final verdict + +### CTI-002 - new access key creation after failed logins + +- Severity: high +- Actor: USER_A +- Primary event: evt-cti-005 +- Evidence event IDs: evt-cti-001, evt-cti-002, evt-cti-003, evt-cti-005 +- ATT&CK mapping: Account Manipulation: Additional Cloud Credentials, Valid Accounts: Cloud Accounts +- Bounded reason: CreateAccessKey for USER_A occurred after 3 failed console login event(s) inside 15 minutes. +- Scope: synthetic reviewer signal only; no production claim or final verdict + +### CTI-003 - policy attachment after unusual source IP + +- Severity: high +- Actor: USER_A +- Primary event: evt-cti-006 +- Evidence event IDs: evt-cti-006 +- ATT&CK mapping: Valid Accounts: Cloud Accounts +- Bounded reason: AttachUserPolicy came from 203.0.113.45, which is not in the demo's expected source IP list. +- Scope: synthetic reviewer signal only; no production claim or final verdict + +### CTI-004 - CloudTrail logging disabled near IAM change + +- Severity: critical +- Actor: USER_A +- Primary event: evt-cti-007 +- Evidence event IDs: evt-cti-005, evt-cti-006, evt-cti-007 +- ATT&CK mapping: Disable or Modify Cloud Log, Valid Accounts: Cloud Accounts +- Bounded reason: StopLogging occurred within 10 minutes of 2 IAM change event(s). +- Scope: synthetic reviewer signal only; no production claim or final verdict + +### CTI-005 - security group ingress opened after identity change + +- Severity: high +- Actor: USER_A +- Primary event: evt-cti-008 +- Evidence event IDs: evt-cti-005, evt-cti-006, evt-cti-008 +- ATT&CK mapping: Modify Cloud Compute Infrastructure, Valid Accounts: Cloud Accounts +- Bounded reason: AuthorizeSecurityGroupIngress opened a world-routable range after 2 IAM change event(s) inside 15 minutes. +- Scope: synthetic reviewer signal only; no production claim or final verdict + +## Boundaries + +- Synthetic CloudTrail-like events only +- No live AWS account +- No real account ID +- No production detection claim +- No final incident verdict diff --git a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json new file mode 100644 index 0000000..466dda5 --- /dev/null +++ b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json @@ -0,0 +1,356 @@ +[ + { + "signal_id": "CTI-001", + "rule_id": "failed_console_login_burst", + "rule_name": "failed console login burst", + "severity": "medium", + "signal_time": "2026-04-07T10:03:05Z", + "actor": "USER_A", + "primary_event_id": "evt-cti-003", + "source_ips": [ + "203.0.113.45" + ], + "evidence_event_ids": [ + "evt-cti-001", + "evt-cti-002", + "evt-cti-003" + ], + "evidence_events": [ + { + "eventID": "evt-cti-001", + "eventTime": "2026-04-07T10:00:00Z", + "actor": "USER_A", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": "FailedAuthentication", + "requestParameters": {} + }, + { + "eventID": "evt-cti-002", + "eventTime": "2026-04-07T10:01:20Z", + "actor": "USER_A", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": "FailedAuthentication", + "requestParameters": {} + }, + { + "eventID": "evt-cti-003", + "eventTime": "2026-04-07T10:03:05Z", + "actor": "USER_A", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": "FailedAuthentication", + "requestParameters": {} + } + ], + "attack_mappings": [ + { + "id": "T1110.003", + "name": "Brute Force: Password Spraying", + "tactic": "Credential Access", + "reference": "https://attack.mitre.org/techniques/T1110/003/" + }, + { + "id": "T1078.004", + "name": "Valid Accounts: Cloud Accounts", + "tactic": "Initial Access / Persistence / Privilege Escalation / Defense Evasion", + "reference": "https://attack.mitre.org/techniques/T1078/004/" + } + ], + "bounded_correlation_reason": "3 failed ConsoleLogin events for USER_A fell inside a 5 minute window.", + "review_scope": "Synthetic signal for reviewer inspection only; it is not a production detection claim and does not assert a final incident verdict." + }, + { + "signal_id": "CTI-002", + "rule_id": "new_access_key_creation_after_failed_logins", + "rule_name": "new access key creation after failed logins", + "severity": "high", + "signal_time": "2026-04-07T10:05:00Z", + "actor": "USER_A", + "primary_event_id": "evt-cti-005", + "source_ips": [ + "203.0.113.45" + ], + "evidence_event_ids": [ + "evt-cti-001", + "evt-cti-002", + "evt-cti-003", + "evt-cti-005" + ], + "evidence_events": [ + { + "eventID": "evt-cti-001", + "eventTime": "2026-04-07T10:00:00Z", + "actor": "USER_A", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": "FailedAuthentication", + "requestParameters": {} + }, + { + "eventID": "evt-cti-002", + "eventTime": "2026-04-07T10:01:20Z", + "actor": "USER_A", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": "FailedAuthentication", + "requestParameters": {} + }, + { + "eventID": "evt-cti-003", + "eventTime": "2026-04-07T10:03:05Z", + "actor": "USER_A", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": "FailedAuthentication", + "requestParameters": {} + }, + { + "eventID": "evt-cti-005", + "eventTime": "2026-04-07T10:05:00Z", + "actor": "USER_A", + "eventSource": "iam.amazonaws.com", + "eventName": "CreateAccessKey", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": null, + "requestParameters": { + "userName": "USER_A" + } + } + ], + "attack_mappings": [ + { + "id": "T1098.001", + "name": "Account Manipulation: Additional Cloud Credentials", + "tactic": "Persistence / Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1098/001/" + }, + { + "id": "T1078.004", + "name": "Valid Accounts: Cloud Accounts", + "tactic": "Initial Access / Persistence / Privilege Escalation / Defense Evasion", + "reference": "https://attack.mitre.org/techniques/T1078/004/" + } + ], + "bounded_correlation_reason": "CreateAccessKey for USER_A occurred after 3 failed console login event(s) inside 15 minutes.", + "review_scope": "Synthetic signal for reviewer inspection only; it is not a production detection claim and does not assert a final incident verdict." + }, + { + "signal_id": "CTI-003", + "rule_id": "policy_attachment_after_unusual_source_ip", + "rule_name": "policy attachment after unusual source IP", + "severity": "high", + "signal_time": "2026-04-07T10:08:00Z", + "actor": "USER_A", + "primary_event_id": "evt-cti-006", + "source_ips": [ + "203.0.113.45" + ], + "evidence_event_ids": [ + "evt-cti-006" + ], + "evidence_events": [ + { + "eventID": "evt-cti-006", + "eventTime": "2026-04-07T10:08:00Z", + "actor": "USER_A", + "eventSource": "iam.amazonaws.com", + "eventName": "AttachUserPolicy", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": null, + "requestParameters": { + "userName": "USER_A", + "policyArn": "arn:aws:iam::aws:policy/PowerUserAccess" + } + } + ], + "attack_mappings": [ + { + "id": "T1078.004", + "name": "Valid Accounts: Cloud Accounts", + "tactic": "Initial Access / Persistence / Privilege Escalation / Defense Evasion", + "reference": "https://attack.mitre.org/techniques/T1078/004/" + } + ], + "bounded_correlation_reason": "AttachUserPolicy came from 203.0.113.45, which is not in the demo's expected source IP list.", + "review_scope": "Synthetic signal for reviewer inspection only; it is not a production detection claim and does not assert a final incident verdict." + }, + { + "signal_id": "CTI-004", + "rule_id": "cloudtrail_logging_disabled_near_iam_change", + "rule_name": "CloudTrail logging disabled near IAM change", + "severity": "critical", + "signal_time": "2026-04-07T10:10:00Z", + "actor": "USER_A", + "primary_event_id": "evt-cti-007", + "source_ips": [ + "203.0.113.45" + ], + "evidence_event_ids": [ + "evt-cti-005", + "evt-cti-006", + "evt-cti-007" + ], + "evidence_events": [ + { + "eventID": "evt-cti-005", + "eventTime": "2026-04-07T10:05:00Z", + "actor": "USER_A", + "eventSource": "iam.amazonaws.com", + "eventName": "CreateAccessKey", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": null, + "requestParameters": { + "userName": "USER_A" + } + }, + { + "eventID": "evt-cti-006", + "eventTime": "2026-04-07T10:08:00Z", + "actor": "USER_A", + "eventSource": "iam.amazonaws.com", + "eventName": "AttachUserPolicy", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": null, + "requestParameters": { + "userName": "USER_A", + "policyArn": "arn:aws:iam::aws:policy/PowerUserAccess" + } + }, + { + "eventID": "evt-cti-007", + "eventTime": "2026-04-07T10:10:00Z", + "actor": "USER_A", + "eventSource": "cloudtrail.amazonaws.com", + "eventName": "StopLogging", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": null, + "requestParameters": { + "name": "ORG_TRAIL_SYNTHETIC" + } + } + ], + "attack_mappings": [ + { + "id": "T1685.002", + "name": "Disable or Modify Cloud Log", + "tactic": "Defense Evasion", + "reference": "https://attack.mitre.org/techniques/T1685/002/" + }, + { + "id": "T1078.004", + "name": "Valid Accounts: Cloud Accounts", + "tactic": "Initial Access / Persistence / Privilege Escalation / Defense Evasion", + "reference": "https://attack.mitre.org/techniques/T1078/004/" + } + ], + "bounded_correlation_reason": "StopLogging occurred within 10 minutes of 2 IAM change event(s).", + "review_scope": "Synthetic signal for reviewer inspection only; it is not a production detection claim and does not assert a final incident verdict." + }, + { + "signal_id": "CTI-005", + "rule_id": "security_group_ingress_opened_after_identity_change", + "rule_name": "security group ingress opened after identity change", + "severity": "high", + "signal_time": "2026-04-07T10:13:00Z", + "actor": "USER_A", + "primary_event_id": "evt-cti-008", + "source_ips": [ + "203.0.113.45" + ], + "evidence_event_ids": [ + "evt-cti-005", + "evt-cti-006", + "evt-cti-008" + ], + "evidence_events": [ + { + "eventID": "evt-cti-005", + "eventTime": "2026-04-07T10:05:00Z", + "actor": "USER_A", + "eventSource": "iam.amazonaws.com", + "eventName": "CreateAccessKey", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": null, + "requestParameters": { + "userName": "USER_A" + } + }, + { + "eventID": "evt-cti-006", + "eventTime": "2026-04-07T10:08:00Z", + "actor": "USER_A", + "eventSource": "iam.amazonaws.com", + "eventName": "AttachUserPolicy", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": null, + "requestParameters": { + "userName": "USER_A", + "policyArn": "arn:aws:iam::aws:policy/PowerUserAccess" + } + }, + { + "eventID": "evt-cti-008", + "eventTime": "2026-04-07T10:13:00Z", + "actor": "USER_A", + "eventSource": "ec2.amazonaws.com", + "eventName": "AuthorizeSecurityGroupIngress", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "errorCode": null, + "requestParameters": { + "groupId": "sg-synthetic001", + "ipPermissions": [ + { + "ipProtocol": "tcp", + "fromPort": 22, + "toPort": 22, + "ipRanges": [ + { + "cidrIp": "0.0.0.0/0", + "description": "synthetic open ingress" + } + ] + } + ] + } + } + ], + "attack_mappings": [ + { + "id": "T1578", + "name": "Modify Cloud Compute Infrastructure", + "tactic": "Defense Evasion", + "reference": "https://attack.mitre.org/techniques/T1578/" + }, + { + "id": "T1078.004", + "name": "Valid Accounts: Cloud Accounts", + "tactic": "Initial Access / Persistence / Privilege Escalation / Defense Evasion", + "reference": "https://attack.mitre.org/techniques/T1078/004/" + } + ], + "bounded_correlation_reason": "AuthorizeSecurityGroupIngress opened a world-routable range after 2 IAM change event(s) inside 15 minutes.", + "review_scope": "Synthetic signal for reviewer inspection only; it is not a production detection claim and does not assert a final incident verdict." + } +] diff --git a/demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json new file mode 100644 index 0000000..93bb760 --- /dev/null +++ b/demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json @@ -0,0 +1,21 @@ +{ + "schema_version": "cloud-iam-change-investigation-demo/v1", + "source_type": "synthetic CloudTrail-like JSONL", + "event_count": 14, + "signal_count": 5, + "rule_counts": { + "cloudtrail_logging_disabled_near_iam_change": 1, + "failed_console_login_burst": 1, + "new_access_key_creation_after_failed_logins": 1, + "policy_attachment_after_unusual_source_ip": 1, + "security_group_ingress_opened_after_identity_change": 1 + }, + "attack_mapping_count": 5, + "boundaries": [ + "Synthetic CloudTrail-like events only", + "No live AWS account", + "No real account ID", + "No production detection claim", + "No final incident verdict" + ] +} 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 new file mode 100644 index 0000000..321a7c9 --- /dev/null +++ b/demos/cloud-iam-change-investigation-demo/artifacts/normalized_cloudtrail_events.json @@ -0,0 +1,387 @@ +[ + { + "eventID": "evt-cti-001", + "eventTime": "2026-04-07T10:00:00Z", + "actor": "USER_A", + "identityType": "IAMUser", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "userAgent": "Mozilla/5.0 synthetic-console", + "errorCode": "FailedAuthentication", + "requestParameters": {}, + "responseElements": { + "ConsoleLogin": "Failure", + "MFAUsed": "No" + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-A", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_A" + }, + "outcome": "failure" + }, + { + "eventID": "evt-cti-002", + "eventTime": "2026-04-07T10:01:20Z", + "actor": "USER_A", + "identityType": "IAMUser", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "userAgent": "Mozilla/5.0 synthetic-console", + "errorCode": "FailedAuthentication", + "requestParameters": {}, + "responseElements": { + "ConsoleLogin": "Failure", + "MFAUsed": "No" + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-A", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_A" + }, + "outcome": "failure" + }, + { + "eventID": "evt-cti-003", + "eventTime": "2026-04-07T10:03:05Z", + "actor": "USER_A", + "identityType": "IAMUser", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "userAgent": "Mozilla/5.0 synthetic-console", + "errorCode": "FailedAuthentication", + "requestParameters": {}, + "responseElements": { + "ConsoleLogin": "Failure", + "MFAUsed": "No" + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-A", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_A" + }, + "outcome": "failure" + }, + { + "eventID": "evt-cti-004", + "eventTime": "2026-04-07T10:04:10Z", + "actor": "USER_A", + "identityType": "IAMUser", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "userAgent": "Mozilla/5.0 synthetic-console", + "errorCode": null, + "requestParameters": {}, + "responseElements": { + "ConsoleLogin": "Success", + "MFAUsed": "No" + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-A", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_A" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-005", + "eventTime": "2026-04-07T10:05:00Z", + "actor": "USER_A", + "identityType": "IAMUser", + "eventSource": "iam.amazonaws.com", + "eventName": "CreateAccessKey", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": { + "userName": "USER_A" + }, + "responseElements": { + "accessKey": { + "userName": "USER_A", + "accessKeyId": "SYNTHETIC_ACCESS_KEY_ID", + "status": "Active" + } + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-A", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_A" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-006", + "eventTime": "2026-04-07T10:08:00Z", + "actor": "USER_A", + "identityType": "IAMUser", + "eventSource": "iam.amazonaws.com", + "eventName": "AttachUserPolicy", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": { + "userName": "USER_A", + "policyArn": "arn:aws:iam::aws:policy/PowerUserAccess" + }, + "responseElements": {}, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-A", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_A" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-007", + "eventTime": "2026-04-07T10:10:00Z", + "actor": "USER_A", + "identityType": "IAMUser", + "eventSource": "cloudtrail.amazonaws.com", + "eventName": "StopLogging", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": { + "name": "ORG_TRAIL_SYNTHETIC" + }, + "responseElements": {}, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-A", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_A" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-008", + "eventTime": "2026-04-07T10:13:00Z", + "actor": "USER_A", + "identityType": "IAMUser", + "eventSource": "ec2.amazonaws.com", + "eventName": "AuthorizeSecurityGroupIngress", + "awsRegion": "us-east-1", + "sourceIPAddress": "203.0.113.45", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": { + "groupId": "sg-synthetic001", + "ipPermissions": [ + { + "ipProtocol": "tcp", + "fromPort": 22, + "toPort": 22, + "ipRanges": [ + { + "cidrIp": "0.0.0.0/0", + "description": "synthetic open ingress" + } + ] + } + ] + }, + "responseElements": { + "return": true + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-A", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_A" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-009", + "eventTime": "2026-04-07T10:20:00Z", + "actor": "ADMIN_USER", + "identityType": "IAMUser", + "eventSource": "iam.amazonaws.com", + "eventName": "ListUsers", + "awsRegion": "us-east-1", + "sourceIPAddress": "198.51.100.10", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": {}, + "responseElements": { + "isTruncated": false + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-ADMIN", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/ADMIN_USER", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "ADMIN_USER" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-010", + "eventTime": "2026-04-07T10:25:00Z", + "actor": "ADMIN_USER", + "identityType": "IAMUser", + "eventSource": "iam.amazonaws.com", + "eventName": "CreateAccessKey", + "awsRegion": "us-east-1", + "sourceIPAddress": "198.51.100.10", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": { + "userName": "SERVICE_USER" + }, + "responseElements": { + "accessKey": { + "userName": "SERVICE_USER", + "accessKeyId": "SYNTHETIC_SERVICE_ACCESS_KEY_ID", + "status": "Active" + } + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-ADMIN", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/ADMIN_USER", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "ADMIN_USER" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-011", + "eventTime": "2026-04-07T10:30:00Z", + "actor": "ADMIN_USER", + "identityType": "IAMUser", + "eventSource": "iam.amazonaws.com", + "eventName": "AttachUserPolicy", + "awsRegion": "us-east-1", + "sourceIPAddress": "198.51.100.10", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": { + "userName": "SERVICE_USER", + "policyArn": "arn:aws:iam::aws:policy/ReadOnlyAccess" + }, + "responseElements": {}, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-ADMIN", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/ADMIN_USER", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "ADMIN_USER" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-012", + "eventTime": "2026-04-07T10:45:00Z", + "actor": "NETWORK_ADMIN", + "identityType": "IAMUser", + "eventSource": "ec2.amazonaws.com", + "eventName": "AuthorizeSecurityGroupIngress", + "awsRegion": "us-east-1", + "sourceIPAddress": "198.51.100.11", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": { + "groupId": "sg-synthetic002", + "ipPermissions": [ + { + "ipProtocol": "tcp", + "fromPort": 443, + "toPort": 443, + "ipRanges": [ + { + "cidrIp": "10.0.0.0/24", + "description": "synthetic internal ingress" + } + ] + } + ] + }, + "responseElements": { + "return": true + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-NETWORK", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/NETWORK_ADMIN", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "NETWORK_ADMIN" + }, + "outcome": "success" + }, + { + "eventID": "evt-cti-013", + "eventTime": "2026-04-07T11:00:00Z", + "actor": "USER_B", + "identityType": "IAMUser", + "eventSource": "signin.amazonaws.com", + "eventName": "ConsoleLogin", + "awsRegion": "us-east-1", + "sourceIPAddress": "192.0.2.88", + "userAgent": "Mozilla/5.0 synthetic-console", + "errorCode": "FailedAuthentication", + "requestParameters": {}, + "responseElements": { + "ConsoleLogin": "Failure", + "MFAUsed": "No" + }, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-USER-B", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_B", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "USER_B" + }, + "outcome": "failure" + }, + { + "eventID": "evt-cti-014", + "eventTime": "2026-04-07T11:30:00Z", + "actor": "SECURITY_AUDITOR", + "identityType": "IAMUser", + "eventSource": "cloudtrail.amazonaws.com", + "eventName": "StopLogging", + "awsRegion": "us-east-1", + "sourceIPAddress": "198.51.100.11", + "userAgent": "aws-cli/synthetic", + "errorCode": null, + "requestParameters": { + "name": "ORG_TRAIL_SYNTHETIC_REVIEW" + }, + "responseElements": {}, + "userIdentity": { + "type": "IAMUser", + "principalId": "SYNTH-AUDITOR", + "arn": "arn:aws:iam::SYNTHETIC_ACCOUNT:user/SECURITY_AUDITOR", + "accountId": "SYNTHETIC_ACCOUNT", + "userName": "SECURITY_AUDITOR" + }, + "outcome": "success" + } +] diff --git a/demos/cloud-iam-change-investigation-demo/config/investigation.yaml b/demos/cloud-iam-change-investigation-demo/config/investigation.yaml new file mode 100644 index 0000000..f71439f --- /dev/null +++ b/demos/cloud-iam-change-investigation-demo/config/investigation.yaml @@ -0,0 +1,69 @@ +input_path: data/raw/synthetic_cloudtrail_like_events.jsonl +artifacts_dir: artifacts +expected_source_ips: + - 198.51.100.10 + - 198.51.100.11 +attack_mappings: + T1078.004: + name: "Valid Accounts: Cloud Accounts" + tactic: "Initial Access / Persistence / Privilege Escalation / Defense Evasion" + reference: "https://attack.mitre.org/techniques/T1078/004/" + T1110.003: + name: "Brute Force: Password Spraying" + tactic: "Credential Access" + reference: "https://attack.mitre.org/techniques/T1110/003/" + T1098.001: + name: "Account Manipulation: Additional Cloud Credentials" + tactic: "Persistence / Privilege Escalation" + reference: "https://attack.mitre.org/techniques/T1098/001/" + T1685.002: + name: "Disable or Modify Cloud Log" + tactic: "Defense Evasion" + reference: "https://attack.mitre.org/techniques/T1685/002/" + T1578: + name: "Modify Cloud Compute Infrastructure" + tactic: "Defense Evasion" + reference: "https://attack.mitre.org/techniques/T1578/" +rules: + failed_console_login_burst: + name: "failed console login burst" + severity: medium + threshold: 3 + window_minutes: 5 + attack_mapping_ids: + - T1110.003 + - T1078.004 + new_access_key_creation_after_failed_logins: + name: "new access key creation after failed logins" + severity: high + lookback_minutes: 15 + attack_mapping_ids: + - T1098.001 + - T1078.004 + policy_attachment_after_unusual_source_ip: + name: "policy attachment after unusual source IP" + severity: high + attack_mapping_ids: + - T1078.004 + cloudtrail_logging_disabled_near_iam_change: + name: "CloudTrail logging disabled near IAM change" + severity: critical + near_window_minutes: 10 + identity_change_event_names: + - CreateAccessKey + - AttachUserPolicy + - AttachRolePolicy + attack_mapping_ids: + - T1685.002 + - T1078.004 + security_group_ingress_opened_after_identity_change: + name: "security group ingress opened after identity change" + severity: high + follow_on_window_minutes: 15 + identity_change_event_names: + - CreateAccessKey + - AttachUserPolicy + - AttachRolePolicy + attack_mapping_ids: + - T1578 + - T1078.004 diff --git a/demos/cloud-iam-change-investigation-demo/data/raw/synthetic_cloudtrail_like_events.jsonl b/demos/cloud-iam-change-investigation-demo/data/raw/synthetic_cloudtrail_like_events.jsonl new file mode 100644 index 0000000..9c86869 --- /dev/null +++ b/demos/cloud-iam-change-investigation-demo/data/raw/synthetic_cloudtrail_like_events.jsonl @@ -0,0 +1,14 @@ +{"eventTime":"2026-04-07T10:00:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-A","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_A"},"eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.45","userAgent":"Mozilla/5.0 synthetic-console","errorCode":"FailedAuthentication","requestParameters":{},"responseElements":{"ConsoleLogin":"Failure","MFAUsed":"No"},"eventID":"evt-cti-001"} +{"eventTime":"2026-04-07T10:01:20Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-A","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_A"},"eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.45","userAgent":"Mozilla/5.0 synthetic-console","errorCode":"FailedAuthentication","requestParameters":{},"responseElements":{"ConsoleLogin":"Failure","MFAUsed":"No"},"eventID":"evt-cti-002"} +{"eventTime":"2026-04-07T10:03:05Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-A","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_A"},"eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.45","userAgent":"Mozilla/5.0 synthetic-console","errorCode":"FailedAuthentication","requestParameters":{},"responseElements":{"ConsoleLogin":"Failure","MFAUsed":"No"},"eventID":"evt-cti-003"} +{"eventTime":"2026-04-07T10:04:10Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-A","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_A"},"eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.45","userAgent":"Mozilla/5.0 synthetic-console","errorCode":null,"requestParameters":{},"responseElements":{"ConsoleLogin":"Success","MFAUsed":"No"},"eventID":"evt-cti-004"} +{"eventTime":"2026-04-07T10:05:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-A","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_A"},"eventSource":"iam.amazonaws.com","eventName":"CreateAccessKey","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.45","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{"userName":"USER_A"},"responseElements":{"accessKey":{"userName":"USER_A","accessKeyId":"SYNTHETIC_ACCESS_KEY_ID","status":"Active"}},"eventID":"evt-cti-005"} +{"eventTime":"2026-04-07T10:08:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-A","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_A"},"eventSource":"iam.amazonaws.com","eventName":"AttachUserPolicy","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.45","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{"userName":"USER_A","policyArn":"arn:aws:iam::aws:policy/PowerUserAccess"},"responseElements":{},"eventID":"evt-cti-006"} +{"eventTime":"2026-04-07T10:10:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-A","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_A"},"eventSource":"cloudtrail.amazonaws.com","eventName":"StopLogging","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.45","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{"name":"ORG_TRAIL_SYNTHETIC"},"responseElements":{},"eventID":"evt-cti-007"} +{"eventTime":"2026-04-07T10:13:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-A","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_A","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_A"},"eventSource":"ec2.amazonaws.com","eventName":"AuthorizeSecurityGroupIngress","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.45","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{"groupId":"sg-synthetic001","ipPermissions":[{"ipProtocol":"tcp","fromPort":22,"toPort":22,"ipRanges":[{"cidrIp":"0.0.0.0/0","description":"synthetic open ingress"}]}]},"responseElements":{"return":true},"eventID":"evt-cti-008"} +{"eventTime":"2026-04-07T10:20:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-ADMIN","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/ADMIN_USER","accountId":"SYNTHETIC_ACCOUNT","userName":"ADMIN_USER"},"eventSource":"iam.amazonaws.com","eventName":"ListUsers","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.10","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{},"responseElements":{"isTruncated":false},"eventID":"evt-cti-009"} +{"eventTime":"2026-04-07T10:25:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-ADMIN","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/ADMIN_USER","accountId":"SYNTHETIC_ACCOUNT","userName":"ADMIN_USER"},"eventSource":"iam.amazonaws.com","eventName":"CreateAccessKey","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.10","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{"userName":"SERVICE_USER"},"responseElements":{"accessKey":{"userName":"SERVICE_USER","accessKeyId":"SYNTHETIC_SERVICE_ACCESS_KEY_ID","status":"Active"}},"eventID":"evt-cti-010"} +{"eventTime":"2026-04-07T10:30:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-ADMIN","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/ADMIN_USER","accountId":"SYNTHETIC_ACCOUNT","userName":"ADMIN_USER"},"eventSource":"iam.amazonaws.com","eventName":"AttachUserPolicy","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.10","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{"userName":"SERVICE_USER","policyArn":"arn:aws:iam::aws:policy/ReadOnlyAccess"},"responseElements":{},"eventID":"evt-cti-011"} +{"eventTime":"2026-04-07T10:45:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-NETWORK","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/NETWORK_ADMIN","accountId":"SYNTHETIC_ACCOUNT","userName":"NETWORK_ADMIN"},"eventSource":"ec2.amazonaws.com","eventName":"AuthorizeSecurityGroupIngress","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.11","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{"groupId":"sg-synthetic002","ipPermissions":[{"ipProtocol":"tcp","fromPort":443,"toPort":443,"ipRanges":[{"cidrIp":"10.0.0.0/24","description":"synthetic internal ingress"}]}]},"responseElements":{"return":true},"eventID":"evt-cti-012"} +{"eventTime":"2026-04-07T11:00:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-USER-B","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/USER_B","accountId":"SYNTHETIC_ACCOUNT","userName":"USER_B"},"eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.88","userAgent":"Mozilla/5.0 synthetic-console","errorCode":"FailedAuthentication","requestParameters":{},"responseElements":{"ConsoleLogin":"Failure","MFAUsed":"No"},"eventID":"evt-cti-013"} +{"eventTime":"2026-04-07T11:30:00Z","userIdentity":{"type":"IAMUser","principalId":"SYNTH-AUDITOR","arn":"arn:aws:iam::SYNTHETIC_ACCOUNT:user/SECURITY_AUDITOR","accountId":"SYNTHETIC_ACCOUNT","userName":"SECURITY_AUDITOR"},"eventSource":"cloudtrail.amazonaws.com","eventName":"StopLogging","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.11","userAgent":"aws-cli/synthetic","errorCode":null,"requestParameters":{"name":"ORG_TRAIL_SYNTHETIC_REVIEW"},"responseElements":{},"eventID":"evt-cti-014"} diff --git a/docs/architecture.md b/docs/architecture.md index b125aec..ce66791 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,11 +5,12 @@ ```mermaid flowchart TD Inputs["Committed sample inputs
JSONL, CSV, YAML configs"] - CLI["Local CLI entrypoints
run, run-ai-demo, run-rule-dedup-demo, run-config-change-demo"] + CLI["Local CLI entrypoints
run, run-ai-demo, run-rule-dedup-demo, run-config-change-demo, run-cloud-iam-change-demo"] Window["telemetry-window-demo
normalize -> windows -> features -> alerts"] AI["ai-assisted-detection-demo
rules -> cases -> JSON-only drafting"] Dedup["rule-evaluation-and-dedup-demo
raw hits -> cooldown -> suppression reasons"] Config["config-change-investigation-demo
config changes -> bounded evidence correlation"] + CloudIAM["cloud-iam-change-investigation-demo
CloudTrail-like events -> IAM change signals"] Artifacts["Reviewer artifacts
CSV, JSON, JSONL, Markdown, PNG"] Review["Reviewer inspection
README, reviewer path, reviewer pack, tests"] @@ -18,10 +19,12 @@ flowchart TD CLI --> AI CLI --> Dedup CLI --> Config + CLI --> CloudIAM Window --> Artifacts AI --> Artifacts Dedup --> Artifacts Config --> Artifacts + CloudIAM --> Artifacts Artifacts --> Review ``` @@ -41,3 +44,4 @@ flowchart TD | `ai-assisted-detection-demo` | Drafts constrained summaries from deterministic cases; does not decide incident outcomes or call tools. | | `rule-evaluation-and-dedup-demo` | Explains cooldown and suppression behavior; does not route alerts. | | `config-change-investigation-demo` | Correlates risky changes with bounded local evidence; does not monitor live infrastructure. | +| `cloud-iam-change-investigation-demo` | Reviews synthetic CloudTrail-like IAM and cloud-control-plane signals; does not connect to AWS or assert incident verdicts. | diff --git a/docs/reviewer-brief.md b/docs/reviewer-brief.md index 34b212a..24dad67 100644 --- a/docs/reviewer-brief.md +++ b/docs/reviewer-brief.md @@ -6,12 +6,13 @@ Telemetry and detection projects often look impressive in screenshots but are ha ## What it does -`telemetry-lab` is a local, file-based portfolio repo with four demos: +`telemetry-lab` is a local, file-based portfolio repo with five demos: - `telemetry-window-demo` for sliding-window features and rule-based alerts - `ai-assisted-detection-demo` for deterministic case grouping plus bounded JSON-only LLM drafting - `rule-evaluation-and-dedup-demo` for cooldown and suppression reasoning - `config-change-investigation-demo` for risky-change evidence correlation +- `cloud-iam-change-investigation-demo` for synthetic CloudTrail-like IAM change investigation signals ## Reviewer Evidence @@ -28,6 +29,7 @@ python -m pip install -e ".[dev]" python -m telemetry_window_demo.cli run --config configs/default.yaml python -m telemetry_window_demo.cli run-rule-dedup-demo python -m telemetry_window_demo.cli run-config-change-demo +python -m telemetry_window_demo.cli run-cloud-iam-change-demo python -m telemetry_window_demo.cli run-ai-demo ``` @@ -46,7 +48,7 @@ The current committed default sample reports: - `24` windows - `12` alerts after a `60` second cooldown -The other demos emit reviewer-facing artifacts such as `dedup_report.md`, `investigation_report.md`, and `case_report.md`. +The other demos emit reviewer-facing artifacts such as `dedup_report.md`, `investigation_report.md`, `investigation_signals.json`, and `case_report.md`. ## What this proves diff --git a/docs/reviewer-pack.md b/docs/reviewer-pack.md index 2c0e0c2..83df660 100644 --- a/docs/reviewer-pack.md +++ b/docs/reviewer-pack.md @@ -14,6 +14,7 @@ Start with the stable demo matrix in [`docs/reviewer-path.md`](reviewer-path.md) | How is AI constrained? | `ai-assisted-detection-demo` | `demos/ai-assisted-detection-demo/artifacts/case_summaries.json`, `demos/ai-assisted-detection-demo/artifacts/audit_traces.jsonl`, guardrails in `demos/ai-assisted-detection-demo/README.md` | | How are duplicate alerts reduced? | `rule-evaluation-and-dedup-demo` | `demos/rule-evaluation-and-dedup-demo/artifacts/rule_hits_before_dedup.json`, `demos/rule-evaluation-and-dedup-demo/artifacts/rule_hits_after_dedup.json`, `demos/rule-evaluation-and-dedup-demo/artifacts/dedup_explanations.json` | | How are risky config changes investigated? | `config-change-investigation-demo` | `demos/config-change-investigation-demo/artifacts/investigation_hits.json`, `demos/config-change-investigation-demo/artifacts/investigation_report.md` | +| How are IAM changes investigated from CloudTrail-like events? | `cloud-iam-change-investigation-demo` | `demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json`, `demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md` | ## Architecture @@ -45,12 +46,13 @@ The current artifact names are reviewer-facing contracts for the v0.7 / v1.0 con | AI-assisted detection demo | `demos/ai-assisted-detection-demo/artifacts/rule_hits.json`, `demos/ai-assisted-detection-demo/artifacts/case_bundles.json`, `demos/ai-assisted-detection-demo/artifacts/case_summaries.json`, `demos/ai-assisted-detection-demo/artifacts/case_report.md`, `demos/ai-assisted-detection-demo/artifacts/audit_traces.jsonl` | | Rule dedup demo | `demos/rule-evaluation-and-dedup-demo/artifacts/rule_hits_before_dedup.json`, `demos/rule-evaluation-and-dedup-demo/artifacts/rule_hits_after_dedup.json`, `demos/rule-evaluation-and-dedup-demo/artifacts/dedup_explanations.json`, `demos/rule-evaluation-and-dedup-demo/artifacts/dedup_report.md` | | Config-change investigation demo | `demos/config-change-investigation-demo/artifacts/change_events_normalized.json`, `demos/config-change-investigation-demo/artifacts/investigation_hits.json`, `demos/config-change-investigation-demo/artifacts/investigation_summary.json`, `demos/config-change-investigation-demo/artifacts/investigation_report.md` | +| Cloud IAM change investigation demo | `demos/cloud-iam-change-investigation-demo/artifacts/normalized_cloudtrail_events.json`, `demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json`, `demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json`, `demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md` | ## v1 Readiness Gate Before treating the repo as v1-style consolidated: -- Keep the four-demo matrix stable, or document any intentional retirement in the README, reviewer path, reviewer pack, roadmap, and tests. +- Keep the five-demo matrix stable, or document any intentional retirement in the README, reviewer path, reviewer pack, roadmap, and tests. - Keep reviewer-visible artifact names stable, or update the artifact naming contract and committed sample outputs in the same change. - Keep `docs/reviewer-path.md`, this reviewer pack, `docs/architecture.md`, and demo READMEs aligned with actual CLI behavior. - Keep README, package metadata, and repository metadata aligned with the local, file-based detection workflow lab framing. @@ -68,6 +70,7 @@ python -m telemetry_window_demo.cli run --config configs/default.yaml python -m telemetry_window_demo.cli run-ai-demo python -m telemetry_window_demo.cli run-rule-dedup-demo python -m telemetry_window_demo.cli run-config-change-demo +python -m telemetry_window_demo.cli run-cloud-iam-change-demo pytest ``` diff --git a/docs/reviewer-path.md b/docs/reviewer-path.md index a1bf3f3..d3cd5a1 100644 --- a/docs/reviewer-path.md +++ b/docs/reviewer-path.md @@ -12,6 +12,7 @@ The repo is intentionally local and file-based so reviewers can verify each work | How is AI constrained? | `ai-assisted-detection-demo` | `demos/ai-assisted-detection-demo/artifacts/case_summaries.json`, `demos/ai-assisted-detection-demo/artifacts/audit_traces.jsonl`, guardrails in `demos/ai-assisted-detection-demo/README.md` | | How are duplicate alerts reduced? | `rule-evaluation-and-dedup-demo` | `demos/rule-evaluation-and-dedup-demo/artifacts/rule_hits_before_dedup.json`, `demos/rule-evaluation-and-dedup-demo/artifacts/rule_hits_after_dedup.json`, `demos/rule-evaluation-and-dedup-demo/artifacts/dedup_explanations.json` | | How are risky config changes investigated? | `config-change-investigation-demo` | `demos/config-change-investigation-demo/artifacts/investigation_hits.json`, `demos/config-change-investigation-demo/artifacts/investigation_report.md` | +| How are IAM changes investigated from CloudTrail-like events? | `cloud-iam-change-investigation-demo` | `demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json`, `demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md` | ## Fast verification commands @@ -25,6 +26,7 @@ python -m telemetry_window_demo.cli run --config configs/default.yaml python -m telemetry_window_demo.cli run-ai-demo python -m telemetry_window_demo.cli run-rule-dedup-demo python -m telemetry_window_demo.cli run-config-change-demo +python -m telemetry_window_demo.cli run-cloud-iam-change-demo pytest ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index bfb9594..9020bc4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,12 +2,13 @@ `telemetry-lab` is moving from demo expansion toward v0.7 / v1.0 consolidation. -The repo now has four reviewer-verifiable demos and a clear [`docs/reviewer-path.md`](reviewer-path.md). The priority is to keep the demo matrix stable, preserve artifact names, and make review faster without implying a SIEM, dashboard, or production monitoring platform. +The repo now has five reviewer-verifiable demos and a clear [`docs/reviewer-path.md`](reviewer-path.md). The priority is to keep the demo matrix stable, preserve artifact names, and make review faster without implying a SIEM, dashboard, or production monitoring platform. Recently added: - [rule-evaluation-and-dedup-demo](../demos/rule-evaluation-and-dedup-demo/README.md) now shows raw rule hits, retained alerts, and suppression reasons side by side. - [config-change-investigation-demo](../demos/config-change-investigation-demo/README.md) now shows risky configuration changes, bounded evidence attachment, and deterministic investigation summaries. +- [cloud-iam-change-investigation-demo](../demos/cloud-iam-change-investigation-demo/README.md) now shows synthetic CloudTrail-like IAM changes, bounded cloud-control-plane signals, and a small ATT&CK mapping set. - [`docs/reviewer-path.md`](reviewer-path.md) maps common review questions to the right demo and artifacts. - [`docs/reviewer-pack.md`](reviewer-pack.md) collects the top-level reviewer flow and artifact naming contract. - [`docs/architecture.md`](architecture.md) describes the local file-based workflow shape. @@ -22,16 +23,11 @@ Recently added: The consolidation gate lives in [`docs/reviewer-pack.md`](reviewer-pack.md#v1-readiness-gate). -## Optional Final Demo +## Final Demo Slot -At most one more demo should be added before v1-style consolidation. +The optional final demo slot is now used by `cloud-iam-change-investigation-demo`. -Good candidates: - -- auth/login anomaly triage from bursty login failures into follow-on signals -- config-change drift follow-up showing rollback attempts and reduced nearby denials - -Only add one if it clearly strengthens the detection workflow portfolio without turning the repo into a platform. +Further work before v1-style consolidation should focus on stability, documentation accuracy, tests, and committed artifact reproducibility rather than adding more workflow surface area. ## Non-Directions diff --git a/src/telemetry_window_demo/cli.py b/src/telemetry_window_demo/cli.py index 26807ec..be84917 100644 --- a/src/telemetry_window_demo/cli.py +++ b/src/telemetry_window_demo/cli.py @@ -121,6 +121,16 @@ def build_parser() -> argparse.ArgumentParser: help="Path to demos/config-change-investigation-demo.", ) run_config_change_demo_parser.set_defaults(func=run_config_change_demo_command) + + run_cloud_iam_demo_parser = subparsers.add_parser( + "run-cloud-iam-change-demo", + help="Run the synthetic CloudTrail-like IAM change investigation demo.", + ) + run_cloud_iam_demo_parser.add_argument( + "--demo-root", + help="Path to demos/cloud-iam-change-investigation-demo.", + ) + run_cloud_iam_demo_parser.set_defaults(func=run_cloud_iam_demo_command) return parser @@ -266,6 +276,20 @@ def run_config_change_demo_command(args: argparse.Namespace) -> None: print(f"[OK] Saved artifacts to {_display_path(result['artifacts_dir'])}") for name, path in result["artifacts"].items(): print(f" - {name}: {_display_path(path)}") + + +def run_cloud_iam_demo_command(args: argparse.Namespace) -> None: + from .cloud_iam_change_investigation_demo import default_demo_root, run_demo + + demo_root = _demo_root_path(args.demo_root, default_demo_root()) + result = run_demo(demo_root=demo_root) + + print(f"[OK] Loaded {result['raw_event_count']} CloudTrail-like events") + print(f"[OK] Evaluated {result['rule_count']} investigation rules") + print(f"[OK] Built {result['signal_count']} investigation signals") + print(f"[OK] Saved artifacts to {_display_path(result['artifacts_dir'])}") + for name, path in result["artifacts"].items(): + print(f" - {name}: {_display_path(path)}") def _display_path(path: Path) -> str: diff --git a/src/telemetry_window_demo/cloud_iam_change_investigation_demo/__init__.py b/src/telemetry_window_demo/cloud_iam_change_investigation_demo/__init__.py new file mode 100644 index 0000000..c63dbdf --- /dev/null +++ b/src/telemetry_window_demo/cloud_iam_change_investigation_demo/__init__.py @@ -0,0 +1,5 @@ +"""Synthetic CloudTrail-like IAM investigation demo pipeline.""" + +from .pipeline import default_demo_root, run_demo + +__all__ = ["default_demo_root", "run_demo"] 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 new file mode 100644 index 0000000..4ad4ab2 --- /dev/null +++ b/src/telemetry_window_demo/cloud_iam_change_investigation_demo/pipeline.py @@ -0,0 +1,865 @@ +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import yaml + +from ..io import ensure_output_directory, ensure_output_file_path +from ..time_utils import parse_utc_timestamp + +CLOUDTRAIL_REQUIRED_FIELDS = ( + "eventTime", + "userIdentity", + "eventSource", + "eventName", + "awsRegion", + "sourceIPAddress", + "userAgent", + "errorCode", + "requestParameters", + "responseElements", + "eventID", +) +REQUIRED_RULE_IDS = ( + "failed_console_login_burst", + "new_access_key_creation_after_failed_logins", + "policy_attachment_after_unusual_source_ip", + "cloudtrail_logging_disabled_near_iam_change", + "security_group_ingress_opened_after_identity_change", +) +SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4} + + +def default_demo_root() -> Path: + return Path(__file__).resolve().parents[3] / "demos" / "cloud-iam-change-investigation-demo" + + +def run_demo( + demo_root: Path | None = None, + artifacts_dir: Path | None = None, +) -> dict[str, Any]: + demo_root = Path(demo_root or default_demo_root()).resolve() + config = validate_demo_config(load_yaml(demo_root / "config" / "investigation.yaml")) + artifacts_dir = Path( + artifacts_dir + or resolve_demo_path(demo_root, str(config["artifacts_dir"])) + ).resolve() + ensure_output_directory(artifacts_dir) + + normalized_events = normalize_cloudtrail_events( + load_jsonl(resolve_demo_path(demo_root, str(config["input_path"]))) + ) + signals = evaluate_cloud_iam_signals(normalized_events, config) + summary = build_investigation_summary(normalized_events, signals, config) + report_text = build_investigation_report(normalized_events, signals, summary) + + paths = { + "normalized_cloudtrail_events": write_json( + normalized_events, + artifacts_dir / "normalized_cloudtrail_events.json", + ), + "investigation_signals": write_json( + signals, + artifacts_dir / "investigation_signals.json", + ), + "investigation_summary": write_json( + summary, + artifacts_dir / "investigation_summary.json", + ), + "investigation_report": write_text( + report_text, + artifacts_dir / "investigation_report.md", + ), + } + + return { + "demo_root": demo_root, + "artifacts_dir": artifacts_dir, + "raw_event_count": len(normalized_events), + "signal_count": len(signals), + "rule_count": len(config["rules"]), + "artifacts": paths, + } + + +def load_yaml(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + if not isinstance(payload, dict): + raise ValueError("YAML config must deserialize into a mapping.") + return payload + + +def load_jsonl(path: Path) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + with path.open("r", encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + raw = line.strip() + if not raw: + continue + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSONL at line {line_number} in {path}") from exc + if not isinstance(payload, dict): + raise ValueError("Expected JSON object records in JSONL input.") + records.append(payload) + return records + + +def validate_demo_config(config: Mapping[str, Any]) -> dict[str, Any]: + input_path = require_non_empty_string(config.get("input_path"), "input_path") + artifacts_dir = require_non_empty_string( + config.get("artifacts_dir", "artifacts"), + "artifacts_dir", + ) + expected_source_ips = require_string_list( + config.get("expected_source_ips", []), + "expected_source_ips", + ) + + attack_mappings = config.get("attack_mappings") + if not isinstance(attack_mappings, Mapping) or not attack_mappings: + raise ValueError("Config field 'attack_mappings' must be a non-empty mapping.") + if len(attack_mappings) > 5: + raise ValueError("Config field 'attack_mappings' must contain at most 5 entries.") + validated_attack_mappings = { + str(mapping_id).strip(): validate_attack_mapping(mapping_id, mapping) + for mapping_id, mapping in attack_mappings.items() + } + + rules = config.get("rules") + if not isinstance(rules, Mapping): + raise ValueError("Config field 'rules' must be a mapping.") + + validated_rules: dict[str, dict[str, Any]] = {} + for rule_id in REQUIRED_RULE_IDS: + raw_rule = rules.get(rule_id) + if not isinstance(raw_rule, Mapping): + raise ValueError(f"Config field 'rules.{rule_id}' must be a mapping.") + validated_rules[rule_id] = validate_rule_config( + rule_id, + raw_rule, + known_mapping_ids=set(validated_attack_mappings), + ) + + return { + "input_path": input_path, + "artifacts_dir": artifacts_dir, + "expected_source_ips": expected_source_ips, + "attack_mappings": validated_attack_mappings, + "rules": validated_rules, + } + + +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.") + 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"), + "tactic": require_non_empty_string( + raw_mapping.get("tactic"), + f"attack_mappings.{mapping_id}.tactic", + ), + "reference": require_non_empty_string( + raw_mapping.get("reference"), + f"attack_mappings.{mapping_id}.reference", + ), + } + + +def validate_rule_config( + rule_id: str, + raw_rule: Mapping[str, Any], + *, + known_mapping_ids: set[str], +) -> dict[str, Any]: + 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() + if severity not in SEVERITY_ORDER: + raise ValueError(f"Rule '{rule_id}' uses unsupported severity '{severity}'.") + attack_mapping_ids = require_string_list( + raw_rule.get("attack_mapping_ids"), + f"rules.{rule_id}.attack_mapping_ids", + ) + unknown_mapping_ids = sorted(set(attack_mapping_ids) - known_mapping_ids) + if unknown_mapping_ids: + raise ValueError( + f"Rule '{rule_id}' references unknown ATT&CK mapping IDs: " + + ", ".join(unknown_mapping_ids) + ) + + rule = { + "name": name, + "severity": severity, + "attack_mapping_ids": attack_mapping_ids, + } + for field in ( + "threshold", + "window_minutes", + "lookback_minutes", + "near_window_minutes", + "follow_on_window_minutes", + ): + if field in raw_rule: + rule[field] = require_positive_int(raw_rule[field], f"rules.{rule_id}.{field}") + if "identity_change_event_names" in raw_rule: + rule["identity_change_event_names"] = require_string_list( + raw_rule["identity_change_event_names"], + f"rules.{rule_id}.identity_change_event_names", + ) + return rule + + +def normalize_cloudtrail_events(raw_events: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + + for index, raw_event in enumerate(raw_events, start=1): + for field in CLOUDTRAIL_REQUIRED_FIELDS: + if field not in raw_event: + raise ValueError(f"CloudTrail-like event {index} is missing field '{field}'.") + + event_id = require_non_empty_string(raw_event["eventID"], f"event {index}.eventID") + if event_id in seen_ids: + raise ValueError(f"Duplicate eventID found in sample input: {event_id}") + seen_ids.add(event_id) + + user_identity = raw_event["userIdentity"] + if not isinstance(user_identity, Mapping): + raise ValueError(f"CloudTrail-like event {event_id} has non-object userIdentity.") + request_parameters = raw_event["requestParameters"] + if not isinstance(request_parameters, Mapping): + raise ValueError(f"CloudTrail-like event {event_id} has non-object requestParameters.") + response_elements = raw_event["responseElements"] + if response_elements is None: + response_elements = {} + if not isinstance(response_elements, Mapping): + raise ValueError(f"CloudTrail-like event {event_id} has non-object responseElements.") + + event_time = parse_timestamp( + require_non_empty_string(raw_event["eventTime"], f"event {index}.eventTime") + ) + event_source = require_non_empty_string( + raw_event["eventSource"], + f"event {index}.eventSource", + ) + event_name = require_non_empty_string(raw_event["eventName"], f"event {index}.eventName") + actor = extract_actor(user_identity) + + normalized.append( + { + "eventID": event_id, + "eventTime": event_time, + "actor": actor, + "identityType": normalize_optional_text(user_identity.get("type")), + "eventSource": event_source, + "eventName": event_name, + "awsRegion": require_non_empty_string( + raw_event["awsRegion"], + f"event {index}.awsRegion", + ), + "sourceIPAddress": require_non_empty_string( + raw_event["sourceIPAddress"], + f"event {index}.sourceIPAddress", + ), + "userAgent": require_non_empty_string(raw_event["userAgent"], f"event {index}.userAgent"), + "errorCode": normalize_optional_text(raw_event.get("errorCode")), + "requestParameters": dict(request_parameters), + "responseElements": dict(response_elements), + "userIdentity": dict(user_identity), + "outcome": classify_outcome(raw_event.get("errorCode"), response_elements), + } + ) + + return sorted( + normalized, + key=lambda event: (format_timestamp(event["eventTime"]), event["eventID"]), + ) + + +def evaluate_cloud_iam_signals( + events: Sequence[Mapping[str, Any]], + config: Mapping[str, Any], +) -> list[dict[str, Any]]: + signals: list[dict[str, Any]] = [] + rules = config["rules"] + + signals.extend( + detect_failed_console_login_burst( + events, + rules["failed_console_login_burst"], + config, + ) + ) + signals.extend( + detect_access_key_after_failed_logins( + events, + rules["new_access_key_creation_after_failed_logins"], + config, + ) + ) + signals.extend( + detect_policy_attachment_after_unusual_source_ip( + events, + rules["policy_attachment_after_unusual_source_ip"], + config, + ) + ) + signals.extend( + detect_cloudtrail_logging_disabled_near_iam_change( + events, + rules["cloudtrail_logging_disabled_near_iam_change"], + config, + ) + ) + signals.extend( + detect_security_group_ingress_after_identity_change( + events, + rules["security_group_ingress_opened_after_identity_change"], + config, + ) + ) + + signals.sort( + key=lambda signal: ( + format_timestamp(signal["signal_time"]), + str(signal["rule_id"]), + str(signal["actor"]), + ) + ) + for index, signal in enumerate(signals, start=1): + signal["signal_id"] = f"CTI-{index:03d}" + return signals + + +def detect_failed_console_login_burst( + events: Sequence[Mapping[str, Any]], + rule: Mapping[str, Any], + config: Mapping[str, Any], +) -> list[dict[str, Any]]: + threshold = int(rule.get("threshold", 3)) + window = timedelta(minutes=int(rule.get("window_minutes", 5))) + failed_logins = [event for event in events if is_failed_console_login(event)] + by_actor: dict[str, list[Mapping[str, Any]]] = {} + for event in failed_logins: + by_actor.setdefault(str(event["actor"]), []).append(event) + + signals: list[dict[str, Any]] = [] + for actor, actor_events in sorted(by_actor.items()): + actor_events = sorted( + actor_events, + key=lambda event: (format_timestamp(event["eventTime"]), str(event["eventID"])), + ) + for index, event in enumerate(actor_events): + window_end = event["eventTime"] + window + burst_events = [ + candidate + for candidate in actor_events[index:] + if candidate["eventTime"] <= window_end + ] + if len(burst_events) < threshold: + continue + signals.append( + build_signal( + rule_id="failed_console_login_burst", + rule=rule, + config=config, + signal_time=burst_events[threshold - 1]["eventTime"], + actor=actor, + primary_event=burst_events[threshold - 1], + evidence_events=burst_events[:threshold], + reason=( + f"{threshold} failed ConsoleLogin events for {actor} fell inside " + f"a {int(rule.get('window_minutes', 5))} minute window." + ), + ) + ) + break + return signals + + +def detect_access_key_after_failed_logins( + events: Sequence[Mapping[str, Any]], + rule: Mapping[str, Any], + config: Mapping[str, Any], +) -> list[dict[str, Any]]: + lookback = timedelta(minutes=int(rule.get("lookback_minutes", 15))) + failed_logins = [event for event in events if is_failed_console_login(event)] + signals: list[dict[str, Any]] = [] + + for event in events: + 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 + nearby_failures = [ + login + for login in failed_logins + if str(login["actor"]) == target_actor + and window_start <= login["eventTime"] <= event["eventTime"] + ] + if not nearby_failures: + continue + signals.append( + build_signal( + rule_id="new_access_key_creation_after_failed_logins", + rule=rule, + config=config, + signal_time=event["eventTime"], + actor=target_actor, + primary_event=event, + evidence_events=[*nearby_failures, event], + reason=( + f"CreateAccessKey for {target_actor} occurred after " + f"{len(nearby_failures)} failed console login event(s) inside " + f"{int(rule.get('lookback_minutes', 15))} minutes." + ), + ) + ) + return signals + + +def detect_policy_attachment_after_unusual_source_ip( + events: Sequence[Mapping[str, Any]], + rule: Mapping[str, Any], + config: Mapping[str, Any], +) -> list[dict[str, Any]]: + expected_source_ips = set(config.get("expected_source_ips", [])) + policy_events = {"AttachUserPolicy", "AttachRolePolicy", "PutUserPolicy", "PutRolePolicy"} + signals: list[dict[str, Any]] = [] + + for event in events: + if not is_successful_event(event, event_source="iam.amazonaws.com"): + continue + if str(event["eventName"]) not in policy_events: + continue + source_ip = str(event["sourceIPAddress"]) + if source_ip in expected_source_ips: + continue + signals.append( + build_signal( + rule_id="policy_attachment_after_unusual_source_ip", + rule=rule, + config=config, + signal_time=event["eventTime"], + actor=str(event["actor"]), + primary_event=event, + evidence_events=[event], + reason=( + f"{event['eventName']} came from {source_ip}, which is not in the " + "demo's expected source IP list." + ), + ) + ) + return signals + + +def detect_cloudtrail_logging_disabled_near_iam_change( + events: Sequence[Mapping[str, Any]], + rule: Mapping[str, Any], + config: Mapping[str, Any], +) -> list[dict[str, Any]]: + near_window = timedelta(minutes=int(rule.get("near_window_minutes", 10))) + identity_change_names = set( + rule.get( + "identity_change_event_names", + ["CreateAccessKey", "AttachUserPolicy", "AttachRolePolicy"], + ) + ) + disable_events = {"StopLogging", "DeleteTrail", "UpdateTrail"} + iam_changes = [ + event + for event in events + if is_successful_event(event, event_source="iam.amazonaws.com") + and str(event["eventName"]) in identity_change_names + ] + signals: list[dict[str, Any]] = [] + + for event in events: + if not is_successful_event(event, event_source="cloudtrail.amazonaws.com"): + continue + if str(event["eventName"]) not in disable_events: + continue + nearby_changes = [ + change + for change in iam_changes + if abs(event["eventTime"] - change["eventTime"]) <= near_window + ] + if not nearby_changes: + continue + signals.append( + build_signal( + rule_id="cloudtrail_logging_disabled_near_iam_change", + rule=rule, + config=config, + signal_time=event["eventTime"], + actor=str(event["actor"]), + primary_event=event, + evidence_events=[*nearby_changes, event], + reason=( + f"{event['eventName']} occurred within " + f"{int(rule.get('near_window_minutes', 10))} minutes of " + f"{len(nearby_changes)} IAM change event(s)." + ), + ) + ) + return signals + + +def detect_security_group_ingress_after_identity_change( + events: Sequence[Mapping[str, Any]], + rule: Mapping[str, Any], + config: Mapping[str, Any], +) -> list[dict[str, Any]]: + follow_on_window = timedelta(minutes=int(rule.get("follow_on_window_minutes", 15))) + identity_change_names = set( + rule.get( + "identity_change_event_names", + ["CreateAccessKey", "AttachUserPolicy", "AttachRolePolicy"], + ) + ) + identity_changes = [ + event + for event in events + if is_successful_event(event, event_source="iam.amazonaws.com") + and str(event["eventName"]) in identity_change_names + ] + signals: list[dict[str, Any]] = [] + + for event in events: + if not is_successful_event( + event, + event_source="ec2.amazonaws.com", + event_name="AuthorizeSecurityGroupIngress", + ): + continue + if not opens_ingress_to_world(event): + continue + window_start = event["eventTime"] - follow_on_window + nearby_changes = [ + change + for change in identity_changes + if window_start <= change["eventTime"] <= event["eventTime"] + ] + if not nearby_changes: + continue + signals.append( + build_signal( + rule_id="security_group_ingress_opened_after_identity_change", + rule=rule, + config=config, + signal_time=event["eventTime"], + actor=str(event["actor"]), + primary_event=event, + evidence_events=[*nearby_changes, event], + reason=( + "AuthorizeSecurityGroupIngress opened a world-routable range after " + f"{len(nearby_changes)} IAM change event(s) inside " + f"{int(rule.get('follow_on_window_minutes', 15))} minutes." + ), + ) + ) + return signals + + +def build_signal( + *, + rule_id: str, + rule: Mapping[str, Any], + config: Mapping[str, Any], + signal_time: datetime, + actor: str, + primary_event: Mapping[str, Any], + evidence_events: Sequence[Mapping[str, Any]], + reason: str, +) -> dict[str, Any]: + attack_mappings = config["attack_mappings"] + return { + "signal_id": "", + "rule_id": rule_id, + "rule_name": str(rule["name"]), + "severity": str(rule["severity"]), + "signal_time": signal_time, + "actor": actor, + "primary_event_id": str(primary_event["eventID"]), + "source_ips": sorted({str(event["sourceIPAddress"]) for event in evidence_events}), + "evidence_event_ids": [str(event["eventID"]) for event in evidence_events], + "evidence_events": [compact_event(event) for event in evidence_events], + "attack_mappings": [ + dict(attack_mappings[mapping_id]) + for mapping_id in rule["attack_mapping_ids"] + ], + "bounded_correlation_reason": reason, + "review_scope": ( + "Synthetic signal for reviewer inspection only; it is not a production " + "detection claim and does not assert a final incident verdict." + ), + } + + +def build_investigation_summary( + events: Sequence[Mapping[str, Any]], + signals: Sequence[Mapping[str, Any]], + config: Mapping[str, Any], +) -> dict[str, Any]: + rule_counts: dict[str, int] = {} + for signal in signals: + rule_counts[str(signal["rule_id"])] = rule_counts.get(str(signal["rule_id"]), 0) + 1 + + return { + "schema_version": "cloud-iam-change-investigation-demo/v1", + "source_type": "synthetic CloudTrail-like JSONL", + "event_count": len(events), + "signal_count": len(signals), + "rule_counts": dict(sorted(rule_counts.items())), + "attack_mapping_count": len(config["attack_mappings"]), + "boundaries": [ + "Synthetic CloudTrail-like events only", + "No live AWS account", + "No real account ID", + "No production detection claim", + "No final incident verdict", + ], + } + + +def build_investigation_report( + events: Sequence[Mapping[str, Any]], + signals: Sequence[Mapping[str, Any]], + summary: Mapping[str, Any], +) -> str: + lines = [ + "# Cloud IAM Change Investigation Demo Report", + "", + "This deterministic demo reviews synthetic CloudTrail-like events for bounded IAM and cloud-control-plane signals.", + "It uses no live AWS account, no real account IDs, no realtime ingestion, and no final incident verdict.", + "", + "## Run Summary", + "", + f"- source_type: {summary['source_type']}", + f"- normalized_events: {len(events)}", + f"- investigation_signals: {len(signals)}", + f"- attack_mapping_count: {summary['attack_mapping_count']}", + "", + "## Signals", + "", + ] + if not signals: + lines.append("No signals were generated from the current sample.") + return "\n".join(lines).rstrip() + "\n" + + for signal in signals: + mapping_names = ", ".join(mapping["name"] for mapping in signal["attack_mappings"]) + lines.extend( + [ + f"### {signal['signal_id']} - {signal['rule_name']}", + "", + f"- Severity: {signal['severity']}", + f"- Actor: {signal['actor']}", + f"- Primary event: {signal['primary_event_id']}", + f"- Evidence event IDs: {', '.join(signal['evidence_event_ids'])}", + f"- ATT&CK mapping: {mapping_names}", + f"- Bounded reason: {signal['bounded_correlation_reason']}", + "- Scope: synthetic reviewer signal only; no production claim or final verdict", + "", + ] + ) + + lines.extend( + [ + "## Boundaries", + "", + "- Synthetic CloudTrail-like events only", + "- No live AWS account", + "- No real account ID", + "- No production detection claim", + "- No final incident verdict", + "", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def is_failed_console_login(event: Mapping[str, Any]) -> bool: + if str(event["eventSource"]) != "signin.amazonaws.com": + return False + if str(event["eventName"]) != "ConsoleLogin": + return False + response_elements = event.get("responseElements", {}) + console_login = "" + if isinstance(response_elements, Mapping): + console_login = str(response_elements.get("ConsoleLogin", "")) + return str(event.get("outcome")) == "failure" or console_login.lower() == "failure" + + +def is_successful_event( + event: Mapping[str, Any], + *, + event_source: str, + event_name: str | None = None, +) -> bool: + if str(event["eventSource"]) != event_source: + return False + if event_name is not None and str(event["eventName"]) != event_name: + return False + return str(event.get("outcome")) == "success" + + +def opens_ingress_to_world(event: Mapping[str, Any]) -> bool: + parameters = event.get("requestParameters", {}) + if not isinstance(parameters, Mapping): + return False + permissions = parameters.get("ipPermissions", []) + if not isinstance(permissions, Sequence) or isinstance(permissions, (str, bytes)): + return False + + for permission in permissions: + if not isinstance(permission, Mapping): + continue + for range_key, cidr_key in (("ipRanges", "cidrIp"), ("ipv6Ranges", "cidrIpv6")): + ranges = permission.get(range_key, []) + if not isinstance(ranges, Sequence) or isinstance(ranges, (str, bytes)): + continue + for ip_range in ranges: + if not isinstance(ip_range, Mapping): + continue + if ip_range.get(cidr_key) in {"0.0.0.0/0", "::/0"}: + return True + return False + + +def compact_event(event: Mapping[str, Any]) -> dict[str, Any]: + return { + "eventID": str(event["eventID"]), + "eventTime": event["eventTime"], + "actor": str(event["actor"]), + "eventSource": str(event["eventSource"]), + "eventName": str(event["eventName"]), + "awsRegion": str(event["awsRegion"]), + "sourceIPAddress": str(event["sourceIPAddress"]), + "errorCode": event.get("errorCode"), + "requestParameters": dict(event.get("requestParameters", {})), + } + + +def classify_outcome(error_code: object, response_elements: Mapping[str, Any]) -> str: + if normalize_optional_text(error_code): + return "failure" + console_login = str(response_elements.get("ConsoleLogin", "")) + if console_login.lower() == "failure": + return "failure" + return "success" + + +def extract_actor(user_identity: Mapping[str, Any]) -> str: + for key in ("userName", "principalId", "arn"): + value = normalize_optional_text(user_identity.get(key)) + if value: + return value + + session_context = user_identity.get("sessionContext") + if isinstance(session_context, Mapping): + issuer = session_context.get("sessionIssuer") + if isinstance(issuer, Mapping): + for key in ("userName", "principalId", "arn"): + value = normalize_optional_text(issuer.get(key)) + if value: + return value + raise ValueError("CloudTrail-like event userIdentity must identify an actor.") + + +def target_identity_name(event: Mapping[str, Any]) -> str | None: + parameters = event.get("requestParameters", {}) + if not isinstance(parameters, Mapping): + return None + for key in ("userName", "roleName", "targetUserName"): + value = normalize_optional_text(parameters.get(key)) + if value: + return value + return None + + +def resolve_demo_path(demo_root: Path, value: str) -> Path: + candidate = Path(value) + if candidate.is_absolute(): + return candidate + return (demo_root / candidate).resolve() + + +def require_non_empty_string(value: object, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Config field '{field_name}' must be a non-empty string.") + return value.strip() + + +def require_string_list(value: object, field_name: str) -> list[str]: + if isinstance(value, str) or not isinstance(value, Sequence): + raise ValueError(f"Config field '{field_name}' must be a list of non-empty strings.") + normalized: list[str] = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + raise ValueError(f"Config field '{field_name}' must be a list of non-empty strings.") + normalized.append(item.strip()) + return normalized + + +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.") + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Config field '{field_name}' must be a positive integer.") from exc + if parsed <= 0: + raise ValueError(f"Config field '{field_name}' must be a positive integer.") + return parsed + + +def normalize_optional_text(value: object) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def parse_timestamp(raw_value: str) -> datetime: + return parse_utc_timestamp(raw_value) + + +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") + + +def write_json(payload: Any, path: Path) -> Path: + path = ensure_output_file_path(path) + path.write_text( + json.dumps(serialize_record(payload), indent=2) + "\n", + encoding="utf-8", + ) + return path + + +def write_text(content: str, path: Path) -> Path: + path = ensure_output_file_path(path) + path.write_text(content, encoding="utf-8", newline="\n") + return path + + +def serialize_record(value: Any) -> Any: + if isinstance(value, datetime): + return format_timestamp(value) + if isinstance(value, Path): + return value.as_posix() + if isinstance(value, dict): + return {key: serialize_record(item) for key, item in value.items()} + if isinstance(value, list): + return [serialize_record(item) for item in value] + return value diff --git a/tests/test_cli_errors.py b/tests/test_cli_errors.py index 7295cf4..88d400e 100644 --- a/tests/test_cli_errors.py +++ b/tests/test_cli_errors.py @@ -283,7 +283,12 @@ def test_main_reports_file_plot_output_dir_without_traceback(tmp_path, capsys) - @pytest.mark.parametrize( "command", - ["run-ai-demo", "run-rule-dedup-demo", "run-config-change-demo"], + [ + "run-ai-demo", + "run-rule-dedup-demo", + "run-config-change-demo", + "run-cloud-iam-change-demo", + ], ) def test_main_reports_file_demo_root_without_traceback(command, tmp_path, capsys) -> None: demo_root = tmp_path / "demo-root" diff --git a/tests/test_cli_subprocess.py b/tests/test_cli_subprocess.py index 888f683..5c795f4 100644 --- a/tests/test_cli_subprocess.py +++ b/tests/test_cli_subprocess.py @@ -109,3 +109,32 @@ def test_plot_command_runs_as_module(tmp_path) -> None: assert (output_dir / "event_count_timeline.png").is_file() assert (output_dir / "error_rate_timeline.png").is_file() assert (output_dir / "alerts_timeline.png").is_file() + + +def test_cloud_iam_demo_command_runs_as_module() -> None: + repo_root = Path(__file__).resolve().parents[1] + + result = subprocess.run( + [ + sys.executable, + "-m", + "telemetry_window_demo.cli", + "run-cloud-iam-change-demo", + ], + cwd=repo_root, + env=_cli_env(repo_root), + text=True, + capture_output=True, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + assert "[OK] Loaded 14 CloudTrail-like events" in result.stdout + assert "[OK] Built 5 investigation signals" in result.stdout + assert ( + repo_root + / "demos" + / "cloud-iam-change-investigation-demo" + / "artifacts" + / "investigation_report.md" + ).is_file() diff --git a/tests/test_cloud_iam_change_investigation_demo.py b/tests/test_cloud_iam_change_investigation_demo.py new file mode 100644 index 0000000..4aed9a9 --- /dev/null +++ b/tests/test_cloud_iam_change_investigation_demo.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import json +import re +import shutil +from pathlib import Path + +import pytest +import yaml + +from telemetry_window_demo.cloud_iam_change_investigation_demo import ( + default_demo_root, + run_demo, +) +from telemetry_window_demo.cloud_iam_change_investigation_demo.pipeline import ( + CLOUDTRAIL_REQUIRED_FIELDS, + evaluate_cloud_iam_signals, + load_jsonl, + load_yaml, + normalize_cloudtrail_events, + opens_ingress_to_world, + validate_demo_config, +) + + +def _demo_inputs(): + demo_root = default_demo_root() + config = validate_demo_config(load_yaml(demo_root / "config" / "investigation.yaml")) + raw_events = load_jsonl( + demo_root / "data" / "raw" / "synthetic_cloudtrail_like_events.jsonl" + ) + normalized_events = normalize_cloudtrail_events(raw_events) + signals = evaluate_cloud_iam_signals(normalized_events, config) + return demo_root, config, raw_events, normalized_events, signals + + +def _load_json_file(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def _copy_demo_root(tmp_path: Path) -> Path: + source_root = default_demo_root() + target_root = tmp_path / "demo-copy" + shutil.copytree(source_root, target_root) + return target_root + + +def test_raw_events_follow_cloudtrail_like_skeleton() -> None: + _, _, raw_events, _, _ = _demo_inputs() + + assert len(raw_events) == 14 + for event in raw_events: + assert set(CLOUDTRAIL_REQUIRED_FIELDS).issubset(event) + + +def test_normalize_cloudtrail_events_is_sorted_and_derives_actor() -> None: + _, _, _, normalized_events, _ = _demo_inputs() + + assert [event["eventID"] for event in normalized_events[:3]] == [ + "evt-cti-001", + "evt-cti-002", + "evt-cti-003", + ] + assert normalized_events[0]["actor"] == "USER_A" + assert normalized_events[0]["outcome"] == "failure" + assert normalized_events[4]["eventName"] == "CreateAccessKey" + assert normalized_events[4]["outcome"] == "success" + + +def test_evaluate_cloud_iam_signals_flags_expected_rules() -> None: + _, _, _, _, signals = _demo_inputs() + + assert [signal["rule_id"] for signal in signals] == [ + "failed_console_login_burst", + "new_access_key_creation_after_failed_logins", + "policy_attachment_after_unusual_source_ip", + "cloudtrail_logging_disabled_near_iam_change", + "security_group_ingress_opened_after_identity_change", + ] + assert [signal["severity"] for signal in signals] == [ + "medium", + "high", + "high", + "critical", + "high", + ] + + +def test_rule_evidence_stays_bounded_to_configured_windows() -> None: + _, _, _, _, signals = _demo_inputs() + + access_key_signal = next( + signal + for signal in signals + if signal["rule_id"] == "new_access_key_creation_after_failed_logins" + ) + logging_signal = next( + signal + for signal in signals + if signal["rule_id"] == "cloudtrail_logging_disabled_near_iam_change" + ) + ingress_signal = next( + signal + for signal in signals + if signal["rule_id"] == "security_group_ingress_opened_after_identity_change" + ) + + assert access_key_signal["evidence_event_ids"] == [ + "evt-cti-001", + "evt-cti-002", + "evt-cti-003", + "evt-cti-005", + ] + assert logging_signal["evidence_event_ids"] == [ + "evt-cti-005", + "evt-cti-006", + "evt-cti-007", + ] + assert "evt-cti-014" not in logging_signal["evidence_event_ids"] + assert ingress_signal["evidence_event_ids"] == [ + "evt-cti-005", + "evt-cti-006", + "evt-cti-008", + ] + + +def test_opens_ingress_to_world_only_matches_world_routable_ranges() -> None: + _, _, _, normalized_events, _ = _demo_inputs() + open_ingress = next(event for event in normalized_events if event["eventID"] == "evt-cti-008") + internal_ingress = next( + event for event in normalized_events if event["eventID"] == "evt-cti-012" + ) + + assert opens_ingress_to_world(open_ingress) is True + assert opens_ingress_to_world(internal_ingress) is False + + +def test_attack_mapping_set_stays_small_and_expected() -> None: + _, config, _, _, signals = _demo_inputs() + + assert set(config["attack_mappings"]) == { + "T1078.004", + "T1110.003", + "T1098.001", + "T1685.002", + "T1578", + } + assert len(config["attack_mappings"]) == 5 + assert all(signal["attack_mappings"] for signal in signals) + + +def test_sample_data_uses_synthetic_identifiers_only() -> None: + demo_root, _, _, _, _ = _demo_inputs() + raw_text = ( + demo_root / "data" / "raw" / "synthetic_cloudtrail_like_events.jsonl" + ).read_text(encoding="utf-8") + + assert "SYNTHETIC_ACCOUNT" in raw_text + assert "AKIA" not in raw_text + assert re.search(r"\b\d{12}\b", raw_text) is None + + +def test_validate_demo_config_rejects_more_than_five_attack_mappings() -> None: + _, config, _, _, _ = _demo_inputs() + config["attack_mappings"]["T0000"] = { + "name": "extra mapping", + "tactic": "test", + "reference": "https://example.com", + } + + with pytest.raises(ValueError, match="at most 5"): + validate_demo_config(config) + + +def test_normalize_cloudtrail_events_reports_missing_required_field() -> None: + _, _, raw_events, _, _ = _demo_inputs() + broken_event = dict(raw_events[0]) + broken_event.pop("sourceIPAddress") + + with pytest.raises(ValueError, match="sourceIPAddress"): + normalize_cloudtrail_events([broken_event]) + + +def test_run_demo_is_deterministic_and_matches_committed_artifacts(tmp_path) -> None: + demo_root, _, _, _, _ = _demo_inputs() + first_dir = tmp_path / "run-one" + second_dir = tmp_path / "run-two" + + first_result = run_demo(demo_root=demo_root, artifacts_dir=first_dir) + second_result = run_demo(demo_root=demo_root, artifacts_dir=second_dir) + + assert first_result["raw_event_count"] == 14 + assert first_result["rule_count"] == 5 + assert first_result["signal_count"] == 5 + assert second_result["signal_count"] == first_result["signal_count"] + + for name in ( + "normalized_cloudtrail_events.json", + "investigation_signals.json", + "investigation_summary.json", + ): + expected = _load_json_file(demo_root / "artifacts" / name) + first = _load_json_file(first_dir / name) + second = _load_json_file(second_dir / name) + assert first == expected + assert second == expected + + expected_report = (demo_root / "artifacts" / "investigation_report.md").read_text( + encoding="utf-8" + ) + assert (first_dir / "investigation_report.md").read_text(encoding="utf-8") == expected_report + assert (second_dir / "investigation_report.md").read_text(encoding="utf-8") == expected_report + + +def test_run_demo_rejects_file_artifacts_dir(tmp_path) -> None: + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.write_text("not a directory\n", encoding="utf-8") + + with pytest.raises(ValueError, match="Output directory path is not a directory"): + run_demo(demo_root=default_demo_root(), artifacts_dir=artifacts_dir) + + +def test_run_demo_reports_config_errors_before_loading_inputs(tmp_path) -> None: + demo_root = _copy_demo_root(tmp_path) + config_path = demo_root / "config" / "investigation.yaml" + config = load_yaml(config_path) + config["rules"]["failed_console_login_burst"]["threshold"] = 0 + config_path.write_text( + yaml.safe_dump(config, sort_keys=False), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="failed_console_login_burst.threshold"): + run_demo(demo_root=demo_root, artifacts_dir=tmp_path / "artifacts") diff --git a/tests/test_reviewer_docs.py b/tests/test_reviewer_docs.py index e5275eb..98bb0f6 100644 --- a/tests/test_reviewer_docs.py +++ b/tests/test_reviewer_docs.py @@ -42,6 +42,14 @@ "demos/config-change-investigation-demo/artifacts/investigation_report.md", ], ), + ( + "How are IAM changes investigated from CloudTrail-like events?", + "cloud-iam-change-investigation-demo", + [ + "demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json", + "demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md", + ], + ), ] STABLE_REVIEWER_ARTIFACTS = [ @@ -70,6 +78,10 @@ "demos/config-change-investigation-demo/artifacts/investigation_hits.json", "demos/config-change-investigation-demo/artifacts/investigation_summary.json", "demos/config-change-investigation-demo/artifacts/investigation_report.md", + "demos/cloud-iam-change-investigation-demo/artifacts/normalized_cloudtrail_events.json", + "demos/cloud-iam-change-investigation-demo/artifacts/investigation_signals.json", + "demos/cloud-iam-change-investigation-demo/artifacts/investigation_summary.json", + "demos/cloud-iam-change-investigation-demo/artifacts/investigation_report.md", ] @@ -197,7 +209,7 @@ def test_reviewer_pack_defines_v1_readiness_gate() -> None: readme = _read_repo_file("README.md") assert "## v1 Readiness Gate" in reviewer_pack - assert "four-demo matrix stable" in reviewer_pack + assert "five-demo matrix stable" in reviewer_pack assert "reviewer-visible artifact names stable" in reviewer_pack assert "package metadata, and repository metadata" in reviewer_pack assert "Regenerate and inspect committed artifacts" in reviewer_pack