diff --git a/docs/adr/0014-cis-metric-filters-for-global-service-detections.md b/docs/adr/0014-cis-metric-filters-for-global-service-detections.md new file mode 100644 index 0000000..57e21ea --- /dev/null +++ b/docs/adr/0014-cis-metric-filters-for-global-service-detections.md @@ -0,0 +1,52 @@ +# Global-service detections as CIS metric-filter alarms, not us-east-1 EventBridge rules + +Detection rules 2 (console sign-in without MFA), 3 (root usage), and 4 (IAM policy changes) +are implemented as **CloudWatch Logs metric-filter alarms in ca-central-1**, using the CIS AWS +Foundations Benchmark filter patterns verbatim — not as EventBridge rules. This ADR records why, +because the original design (EventBridge rules for all single-occurrence signals) was wrong in a +way that only surfaced when two other controls collided. + +**The collision.** EventBridge delivers events from global services — IAM, STS, and console +sign-in — **only to the us-east-1 event bus** (AWS EventBridge troubleshooting guide: "events +from API calls from global services are only available in that region"). Rules 2/3/4 as +originally deployed on the ca-central-1 bus could never match (issue 007). The AWS-documented +fix is to create the rules in us-east-1. But the region-lock SCP (ADR-0009) explicitly denies +non-`ca-central-1` actions for workloads-account principals, and `events:*` is not — and should +not be — in its exemption list. Our own guardrail forbids the vendor-recommended fix. + +**Options considered.** +- *(a) us-east-1 EventBridge rules in the workloads account* — requires punching an `events:*` + hole in the region-lock SCP. Rejected: weakens the headline guardrail to serve a secondary + control. +- *(b) us-east-1 rules in the SCP-exempt management account, cross-account/cross-region routed* + — works, but adds cross-account event-bus permissions, an IAM routing role, and puts workload + alerting infrastructure in the account that is supposed to stay near-empty (ADR-0009). + Rejected as disproportionate. +- *(c) CloudWatch Logs metric-filter alarms in ca-central-1* — **chosen.** The member trail is + multi-region with `include_global_service_events`, so IAM/sign-in events already land in the + ca-central-1 log group that rule 6 reads. Three metric filters + alarms replicate an existing + in-repo pattern with zero new plumbing. + +**Why (c) is canonical, not a workaround.** The CIS AWS Foundations Benchmark specifies exactly +these three detections as metric filters + alarms on a CloudTrail log group (CIS v1.4.0 controls +4.2, 4.3, 4.4), and Security Hub ships controls (CloudWatch.1, CloudWatch.3, CloudWatch.4) that +check for those **exact** filter patterns — they fail if terms are added or removed, so the +patterns are used verbatim and must not be "improved". A side benefit: Security Hub only credits +trails owned by the evaluated account in the evaluated region, which the member trail satisfies; +enabling Security Hub's CIS standard would score these controls PASS as-built. + +**Consequences.** +- Alert latency on rules 2/3/4 is **~5–10 minutes** (CloudTrail → CW Logs delivery plus alarm + evaluation) versus seconds on the EventBridge rules. Accepted for low-volume, + any-occurrence-is-suspicious signals; recorded as Honest Residual Risk #9 in `plan.md`. + Production fix: a delegated-admin security account outside the SCP's scope. +- Alarm emails carry the alarm name, not the event payload; triage requires a CloudWatch Logs + Insights query against the log group (runbook). +- Rule 2 is **expected-but-logged** for IAM Identity Center sign-ins: federated `ConsoleLogin` + events record `MFAUsed: No` (MFA happens at the IdP), so every SSO console login fires it. + Same triage posture as rule 8; runbook "Expected alerts" documents it. +- Rule 9's tamper watch was extended to the new transport's suppression surface: + `DeleteMetricFilter`, `DeleteAlarms`, `DisableAlarmActions` alongside the EventBridge + operations. +- The detection pipeline is now 5 EventBridge rules + 4 metric-filter alarms; the "nine rules" + framing and the per-rule table in `plan.md` §Detection Pipeline reflect this. diff --git a/docs/runbook.md b/docs/runbook.md index 8e7ffca..6216d66 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -156,8 +156,18 @@ terraform -chdir=infra/loonvault output api_endpoint ## Expected alerts on first apply -Two detection rules can fire legitimately during or immediately after `loonvault-apply`. -Both are expected and do not indicate a breach. +Three detection rules can fire legitimately during or immediately after `loonvault-apply`. +All are expected and do not indicate a breach. + +**Rule 2 -- Console sign-in without MFA (fires on every SSO console login)** + +The CIS 4.2 filter pattern (used verbatim — Security Hub checks it) alerts when a +`ConsoleLogin` event records `MFAUsed: No`. IAM Identity Center sign-ins always record +`MFAUsed: No` because MFA is enforced at the IdP, invisible to the CloudTrail event. So +every SSO console login — including your own — produces this alert ~5–10 minutes later +(metric-filter path, ADR-0014). Same triage posture as rule 8: acknowledge sign-ins you +performed; treat any `ConsoleLogin` you did not perform as a compromise indicator (it +would be an IAM-user or root sign-in path that bypassed Identity Center). **Rule 8 -- Anomalous GetSecretValue (fires on every apply)** @@ -183,13 +193,15 @@ When this alert arrives: **Rule 9 -- Detection rule tampered (fires on every apply)** -`loonvault-apply` calls `events:PutRule` on every EventBridge rule it manages. The -tamper detection rule (rule 9) watches `DeleteRule`, `DisableRule`, and `RemoveTargets` --- not `PutRule` -- so Terraform applies do not trigger it. Note `loonvault-destroy` -*does* call `DeleteRule` on every rule; whether that delivers an alert before the SNS -topic is torn down is timing-dependent (CloudTrail to EventBridge latency vs. teardown -speed) and is being validated (see issue 010). Treat a rule-9 alert as a real -suppression attempt only if it fires **outside an apply or destroy window**. +`loonvault-apply` calls `events:PutRule` / `logs:PutMetricFilter` on every detection +resource it manages. The tamper detection rule (rule 9) watches suppression-class +operations only (`DeleteRule`, `DisableRule`, `RemoveTargets`, `DeleteMetricFilter`, +`DeleteAlarms`, `DisableAlarmActions`) -- not the Put* calls -- so Terraform applies do +not trigger it. Note `loonvault-destroy` *does* call the delete operations on every rule, +filter, and alarm; whether that delivers an alert before the SNS topic is torn down is +timing-dependent (CloudTrail to EventBridge latency vs. teardown speed) and is being +validated (see issue 010). Treat a rule-9 alert as a real suppression attempt only if it +fires **outside an apply or destroy window**. ## Destroy (after the interview) diff --git a/docs/threat-model.md b/docs/threat-model.md index a80a89d..148f897 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -247,11 +247,11 @@ The **AI** actor is a force-multiplier on OE and TE: published code that would r | T-038 | E | EventBridge detection rules modified or deleted to suppress specific alerts | TE MI | 8 | 3 | 4 | 5 | 7 | **5.4** | Medium | | T-039 | R | AccessDenied spike CloudWatch alarm misconfigured or metric filter targeting wrong log group | MI | 7 | 4 | 5 | 5 | 4 | **5.0** | Medium | -**Mitigating controls:** EventBridge rule fires on `StopLogging`/`DeleteTrail`/`UpdateTrail` (1 occurrence → SNS); CloudTrail log bucket has deletion-deny policy (`s3:DeleteObject` denied, including root); CloudTrail log file validation detects tampering; CloudTrail management events capture EventBridge rule changes; nine detection rules cover the highest-signal events across all six B-13 Cyber Security principles; SNS alerts have confirmed subscriber (email); EventBridge rule fires on `DeleteRule`, `DisableRule`, `RemoveTargets` (detection rule 9, G-05). +**Mitigating controls:** EventBridge rule fires on `StopLogging`/`DeleteTrail`/`UpdateTrail` (1 occurrence → SNS); CloudTrail log bucket has deletion-deny policy (`s3:DeleteObject` denied, including root); CloudTrail log file validation detects tampering; CloudTrail management events capture detection-infrastructure changes; nine detection rules — five EventBridge rules plus four CIS metric-filter alarms (ADR-0014) — cover the highest-signal events across all six B-13 Cyber Security principles; SNS alerts have confirmed subscriber (email); rule 9 fires on suppression of either transport: `DeleteRule`/`DisableRule`/`RemoveTargets` (EventBridge) and `DeleteMetricFilter`/`DeleteAlarms`/`DisableAlarmActions` (metric filters/alarms) (G-05). **Gap, T-036:** The EventBridge alert fires *after* CloudTrail is disabled; an attacker who disables CloudTrail, acts, then re-enables it has an unlogged window of seconds-to-minutes. This is inherent to detection-based audit trail protection. -**Gap: T-038 -- CLOSED (G-05).** EventBridge rule 9 fires on `DeleteRule`, `DisableRule`, and `RemoveTargets` (`source: aws.events`, `eventSource: events.amazonaws.com`) → SNS (`infra/loonvault/detection.tf`). `PutRule` is deliberately excluded: every `loonvault-apply` fires it, making it too noisy for a single-developer workflow. Residual limitation: rule 9 cannot detect its own deletion (self-referential detection problem). Production fix: cross-account CloudTrail delivery to a dedicated monitoring account whose rules cannot be modified by a compromised workloads-account principal. +**Gap: T-038 -- CLOSED (G-05).** EventBridge rule 9 fires on suppression-class operations against **both detection transports**: `DeleteRule`, `DisableRule`, `RemoveTargets` (`eventSource: events.amazonaws.com`) and — since rules 2/3/4 moved to CIS metric-filter alarms (ADR-0014) — `DeleteMetricFilter` (`logs.amazonaws.com`) and `DeleteAlarms`/`DisableAlarmActions` (`monitoring.amazonaws.com`) → SNS (`infra/loonvault/detection.tf`). `PutRule`/`PutMetricFilter` are deliberately excluded: every `loonvault-apply` fires them, making them too noisy for a single-developer workflow. Residual limitation: rule 9 cannot detect its own deletion (self-referential detection problem). Production fix: cross-account CloudTrail delivery to a dedicated monitoring account whose rules cannot be modified by a compromised workloads-account principal. --- @@ -307,7 +307,7 @@ These threats arise specifically from the public posture: full code publication **Mitigating controls (T-048):** Lambda authorizer correctness is the sole control, unit tests covering all path-normalisation edge cases (URL encoding, trailing slash, case variants, double slashes) must be comprehensive and form part of the CI gate. Security by design: the authorizer's dual-validation (origin secret + CF Access JWT) means finding one edge case is insufficient, both checks must be bypassed simultaneously for admin access. -**Mitigating controls (T-049):** The published detection thresholds are a known and accepted tradeoff of the public posture. Eight of the nine detection rules are single-occurrence for high-signal events (root usage, CloudTrail disabled, IAM policy widened, SG changed, no-MFA sign-in, KMS key lifecycle, anomalous GetSecretValue, detection rule tampering): these cannot be evaded by staying below a threshold. Only the AccessDenied spike (>5 in 5 min) is threshold-based. Mitigation: consider lowering the threshold post-deploy or adding a secondary metric for slower enumeration (e.g., >20 AccessDenied in 30 min). The detection rule configuration intentionally errs toward false positives over false negatives for high-signal events. +**Mitigating controls (T-049):** The published detection thresholds are a known and accepted tradeoff of the public posture. Eight of the nine detection rules are single-occurrence for high-signal events (root usage, CloudTrail disabled, IAM policy changed, SG changed, no-MFA sign-in, KMS key lifecycle, anomalous GetSecretValue, detection rule tampering): these cannot be evaded by staying below a threshold — the three that run as CIS metric-filter alarms (rules 2/3/4, ADR-0014) alarm at ≥ 1 event in 5 minutes, which any single event trips. Only the AccessDenied spike (>5 in 5 min) is threshold-based. Mitigation: consider lowering the threshold post-deploy or adding a secondary metric for slower enumeration (e.g., >20 AccessDenied in 30 min). The detection rule configuration intentionally errs toward false positives over false negatives for high-signal events. **Mitigating controls (T-050):** SSM parameter *paths* are published; the *values* are not. The X-Origin-Secret value is set at runtime and not stored in code or documentation. Mitigation: enforce SecureString type for all SSM parameters containing security-sensitive values (see Gap G-01). Even with the path known, reading a SecureString requires both IAM `ssm:GetParameter` permission and KMS `kms:Decrypt` permission on the parameter's CMK. diff --git a/infra/loonvault/detection.tf b/infra/loonvault/detection.tf index adb6254..d78ac10 100644 --- a/infra/loonvault/detection.tf +++ b/infra/loonvault/detection.tf @@ -1,11 +1,20 @@ # Detection pipeline: 9 rules # -# 8 EventBridge rules → SNS (single-occurrence, high-signal events) -# 1 CW metric filter → alarm → SNS (rate-based: AccessDenied spike) +# 5 EventBridge rules → SNS (regional services; near-real-time) +# 4 CW metric filters → alarm → SNS (rules 2/3/4: global-service events; +# rule 6: rate-based AccessDenied spike) +# +# Why two transports (ADR-0014): EventBridge only sees global-service events +# (IAM, console sign-in) on the us-east-1 bus, and the region-lock SCP +# (ADR-0009) denies creating rules there. The multi-region member trail already +# delivers those same events into the ca-central-1 log group, so rules 2/3/4 use +# the CIS AWS Foundations metric-filter pattern instead — the same mechanism +# Security Hub's CloudWatch.1/.3/.4 controls check for verbatim. Trade-off: +# ~5-10 min alert latency vs seconds, documented in plan.md residuals. # # Infrastructure: a member-account CloudTrail trail delivering management events -# to CloudWatch Logs. This enables the AccessDenied metric filter (rule 6) and -# captures IAM/sign-in events from us-east-1 into the ca-central-1 log group via +# to CloudWatch Logs. This feeds all four metric filters and captures IAM/ +# sign-in events from us-east-1 into the ca-central-1 log group via # multi-region delivery. The organisation-wide trail in the management account is # the durable, tamper-resistant record; this trail's purpose is CW Logs access. # First trail per account per region: management events are free. @@ -149,7 +158,7 @@ resource "aws_iam_role_policy" "cloudtrail_cwl" { resource "aws_cloudtrail" "main" { #checkov:skip=CKV_AWS_35:Log files are SSE-S3 encrypted at the bucket; the durable org trail uses a dedicated CMK — a CMK here would add cloudtrail grants to the shared key for an ephemeral trail - #checkov:skip=CKV_AWS_252:No per-log-file SNS delivery notifications — alerting flows through the EventBridge rules and the metric-filter alarm on this trail's events + #checkov:skip=CKV_AWS_252:No per-log-file SNS delivery notifications — alerting flows through the EventBridge rules and the metric-filter alarms on this trail's events name = local.name_prefix s3_bucket_name = aws_s3_bucket.cloudtrail.id is_multi_region_trail = true @@ -185,9 +194,11 @@ resource "aws_cloudwatch_metric_alarm" "transform_dlq_depth" { ok_actions = [aws_sns_topic.alerts.arn] } -# ── EventBridge rules 1–5, 7–9 → SNS ──────────────────────────────────────── +# ── EventBridge rules 1, 5, 7, 9 → SNS (regional services) ────────────────── # EventBridge publishes to SNS via the SNS resource policy (aws_sns_topic_policy # in main.tf). No IAM role_arn is needed on SNS targets. +# Rules 2/3/4 are NOT here: their events are global-service (us-east-1 bus only) +# and live as CIS metric-filter alarms further down (ADR-0014). # Rule 1: CloudTrail disabled (B-13: Security monitoring & response) resource "aws_cloudwatch_event_rule" "cloudtrail_disabled" { @@ -209,75 +220,6 @@ resource "aws_cloudwatch_event_target" "cloudtrail_disabled" { arn = aws_sns_topic.alerts.arn } -# Rule 2: Console sign-in without MFA (B-13: Identity & access management) -resource "aws_cloudwatch_event_rule" "no_mfa_signin" { - name = "${local.name_prefix}-no-mfa-signin" - description = "Rule 2: Console sign-in succeeded without MFA" - event_pattern = jsonencode({ - source = ["aws.signin"] - detail-type = ["AWS Console Sign In via CloudTrail"] - detail = { - eventName = ["ConsoleLogin"] - additionalEventData = { - MFAUsed = ["No"] - } - } - }) -} - -resource "aws_cloudwatch_event_target" "no_mfa_signin" { - rule = aws_cloudwatch_event_rule.no_mfa_signin.name - target_id = "alerts" - arn = aws_sns_topic.alerts.arn -} - -# Rule 3: Root account used (B-13: Identity & access management) -# No source filter: root can call any service. Matches both API calls and sign-ins. -resource "aws_cloudwatch_event_rule" "root_usage" { - name = "${local.name_prefix}-root-usage" - description = "Rule 3: Any action by the root account" - event_pattern = jsonencode({ - detail-type = ["AWS API Call via CloudTrail", "AWS Console Sign In via CloudTrail"] - detail = { - userIdentity = { - type = ["Root"] - } - } - }) -} - -resource "aws_cloudwatch_event_target" "root_usage" { - rule = aws_cloudwatch_event_rule.root_usage.name - target_id = "alerts" - arn = aws_sns_topic.alerts.arn -} - -# Rule 4: IAM policy changed (B-13: Identity & access management) -resource "aws_cloudwatch_event_rule" "iam_policy_change" { - name = "${local.name_prefix}-iam-policy-change" - description = "Rule 4: IAM policy attached, created, or modified" - event_pattern = jsonencode({ - source = ["aws.iam"] - detail-type = ["AWS API Call via CloudTrail"] - detail = { - eventSource = ["iam.amazonaws.com"] - eventName = [ - "AttachRolePolicy", "AttachUserPolicy", "AttachGroupPolicy", - "CreatePolicy", "CreatePolicyVersion", - "PutRolePolicy", "PutUserPolicy", "PutGroupPolicy", - "DetachRolePolicy", "DetachUserPolicy", "DetachGroupPolicy", - "DeleteUserPolicy", "DeleteRolePolicy", - ] - } - }) -} - -resource "aws_cloudwatch_event_target" "iam_policy_change" { - rule = aws_cloudwatch_event_rule.iam_policy_change.name - target_id = "alerts" - arn = aws_sns_topic.alerts.arn -} - # Rule 5: Security group rule changed (B-13: Infrastructure security) resource "aws_cloudwatch_event_rule" "sg_change" { name = "${local.name_prefix}-sg-change" @@ -353,18 +295,24 @@ resource "aws_cloudwatch_event_target" "secrets_access" { } # Rule 9: Detection rule tampered (B-13: Security monitoring & response, G-05) -# Watches suppression-class operations only. PutRule is excluded: every loonvault-apply -# fires it on managed rules, producing false positives. Residual: rule 9 cannot detect -# its own deletion (self-referential problem — see T-038 in threat model). +# Watches suppression-class operations only, across BOTH detection transports: +# EventBridge rules (DeleteRule/DisableRule/RemoveTargets) and the CIS metric-filter +# alarms (DeleteMetricFilter/DeleteAlarms/DisableAlarmActions — rules 2/3/4/6 live +# there, ADR-0014). PutRule/PutMetricFilter are excluded: every loonvault-apply fires +# them on managed resources, producing false positives. No source filter: matching on +# eventSource alone avoids guessing each service's EventBridge source name. Residual: +# rule 9 cannot detect its own deletion (self-referential problem — see T-038). resource "aws_cloudwatch_event_rule" "detection_tampering" { name = "${local.name_prefix}-detection-tampering" - description = "Rule 9: EventBridge rule deleted, disabled, or detargeted (G-05)" + description = "Rule 9: detection rule, metric filter, or alarm deleted or disabled (G-05)" event_pattern = jsonencode({ - source = ["aws.events"] detail-type = ["AWS API Call via CloudTrail"] detail = { - eventSource = ["events.amazonaws.com"] - eventName = ["DeleteRule", "DisableRule", "RemoveTargets"] + eventSource = ["events.amazonaws.com", "logs.amazonaws.com", "monitoring.amazonaws.com"] + eventName = [ + "DeleteRule", "DisableRule", "RemoveTargets", + "DeleteMetricFilter", "DeleteAlarms", "DisableAlarmActions", + ] } }) } @@ -408,3 +356,107 @@ resource "aws_cloudwatch_metric_alarm" "access_denied_spike" { treat_missing_data = "notBreaching" alarm_actions = [aws_sns_topic.alerts.arn] } + +# ── Rules 2–4: global-service detections as CIS metric-filter alarms ───────── +# These events (console sign-in, IAM, root) are global-service: EventBridge only +# delivers them to the us-east-1 bus, which the region-lock SCP (ADR-0009) puts +# off-limits. The multi-region trail already lands them in this log group, so +# they use the CIS AWS Foundations Benchmark metric-filter patterns VERBATIM — +# Security Hub's CloudWatch.1/.3/.4 controls fail if any term is added or +# removed, so do not "improve" the patterns. Alert latency ~5-10 min (trail → +# CW Logs delivery + alarm evaluation); accepted trade-off per ADR-0014. + +# Rule 2: Console sign-in without MFA (B-13: Identity & access management) +# CIS 4.2 / Security Hub CloudWatch.3. EXPECTED ALERT: IAM Identity Center +# sign-ins record MFAUsed="No" on the ConsoleLogin event (MFA happens at the +# IdP, invisible here), so every SSO console login fires this — same +# expected-but-logged posture as rule 8. See runbook "Expected alerts". +resource "aws_cloudwatch_log_metric_filter" "no_mfa_signin" { + name = "${local.name_prefix}-no-mfa-signin" + log_group_name = aws_cloudwatch_log_group.cloudtrail.name + pattern = "{($.eventName=\"ConsoleLogin\") && ($.additionalEventData.MFAUsed !=\"Yes\")}" + + metric_transformation { + name = "NoMfaSigninCount" + namespace = "LoonVault/Security" + value = "1" + default_value = "0" + unit = "Count" + } +} + +resource "aws_cloudwatch_metric_alarm" "no_mfa_signin" { + alarm_name = "${local.name_prefix}-no-mfa-signin" + alarm_description = "Rule 2: Console sign-in without MFA (CIS 4.2; SSO logins expected — see runbook)" + namespace = "LoonVault/Security" + metric_name = "NoMfaSigninCount" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.alerts.arn] +} + +# Rule 3: Root account used (B-13: Identity & access management) +# CIS 4.3 / Security Hub CloudWatch.1. Better than the old EventBridge pattern: +# invokedBy/AwsServiceEvent exclusions suppress AWS-service-initiated root noise. +resource "aws_cloudwatch_log_metric_filter" "root_usage" { + name = "${local.name_prefix}-root-usage" + log_group_name = aws_cloudwatch_log_group.cloudtrail.name + pattern = "{$.userIdentity.type=\"Root\" && $.userIdentity.invokedBy NOT EXISTS && $.eventType !=\"AwsServiceEvent\"}" + + metric_transformation { + name = "RootUsageCount" + namespace = "LoonVault/Security" + value = "1" + default_value = "0" + unit = "Count" + } +} + +resource "aws_cloudwatch_metric_alarm" "root_usage" { + alarm_name = "${local.name_prefix}-root-usage" + alarm_description = "Rule 3: Root account activity — root should never be used day-to-day (CIS 4.3)" + namespace = "LoonVault/Security" + metric_name = "RootUsageCount" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.alerts.arn] +} + +# Rule 4: IAM policy changed (B-13: Identity & access management) +# CIS 4.4 / Security Hub CloudWatch.4 — the full 16-event CIS list (the old +# EventBridge rule was missing DeletePolicy and DeletePolicyVersion). +resource "aws_cloudwatch_log_metric_filter" "iam_policy_change" { + name = "${local.name_prefix}-iam-policy-change" + log_group_name = aws_cloudwatch_log_group.cloudtrail.name + pattern = "{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}" + + metric_transformation { + name = "IamPolicyChangeCount" + namespace = "LoonVault/Security" + value = "1" + default_value = "0" + unit = "Count" + } +} + +resource "aws_cloudwatch_metric_alarm" "iam_policy_change" { + alarm_name = "${local.name_prefix}-iam-policy-change" + alarm_description = "Rule 4: IAM policy created, changed, attached, or deleted (CIS 4.4)" + namespace = "LoonVault/Security" + metric_name = "IamPolicyChangeCount" + statistic = "Sum" + period = 300 + evaluation_periods = 1 + threshold = 0 + comparison_operator = "GreaterThanThreshold" + treat_missing_data = "notBreaching" + alarm_actions = [aws_sns_topic.alerts.arn] +} diff --git a/plan.md b/plan.md index 0a3d66e..a9b5022 100644 --- a/plan.md +++ b/plan.md @@ -142,9 +142,10 @@ public keys at `https://loonvault.cloudflareaccess.com/cdn-cgi/access/certs`). ## Detection Pipeline -Nine rules. EventBridge for single-occurrence signals (one event = already suspicious). -CloudWatch Logs metric filter for rate-based signals (spike detection requires counting across -time: EventBridge has no memory across events). +Nine rules across two transports. EventBridge for single-occurrence signals on regional +services (one event = already suspicious, alert in seconds). CloudWatch Logs metric-filter +alarms for the rest: rate-based signals (spike detection requires counting across time: +EventBridge has no memory across events) and global-service signals (see Region placement). **Two trails, by design.** A persistent **organization trail** in the management account is the durable, tamper-resistant audit record (member accounts cannot modify, delete, or read it: the @@ -153,30 +154,42 @@ CloudWatch Logs group *inside* the member account, which is what rule 6's metric org trail delivers to the management account, where a member-account filter cannot reach it). The org trail keeps a durable audit record independently of the ephemeral stack. -**Region placement.** Rules keyed on global-service events (IAM policy changes, console sign-in, -root activity) are deployed in **us-east-1**, because AWS delivers global-service events only to -the us-east-1 event bus; the remaining rules run in ca-central-1 alongside the stack. The -multi-region member trail captures global-service events in its logs in either Region. +**Region placement (ADR-0014).** AWS delivers global-service events (IAM policy changes, +console sign-in, root activity) only to the **us-east-1** event bus — and the region-lock SCP +(ADR-0009) denies creating EventBridge rules there. Rules 2/3/4 therefore run as **CloudWatch +Logs metric-filter alarms in ca-central-1**, reading the multi-region member trail's log group, +which already captures global-service events. The filter patterns are the CIS AWS Foundations +Benchmark patterns **verbatim** (Security Hub controls CloudWatch.1/.3/.4 check for these exact +patterns and fail if terms are added). Trade-off: ~5–10 min alert latency on those three rules +instead of seconds (see Honest Residual Risks). | # | Signal | B-13 Principle | Tool | Pattern / Filter | Threshold | |---|---|---|---|---|---| | 1 | CloudTrail disabled | Security monitoring & response | EventBridge → SNS | `eventName`: `StopLogging`, `DeleteTrail`, `UpdateTrail` | 1 occurrence | -| 2 | Console sign-in without MFA | Identity & access management | EventBridge → SNS | `source: aws.signin`, `MFAUsed: No` | 1 occurrence | -| 3 | Root account usage | Identity & access management | EventBridge → SNS | `userIdentity.type: Root` | 1 occurrence | -| 4 | IAM policy widened | Identity & access management | EventBridge → SNS | `eventName`: `PutUserPolicy`, `AttachRolePolicy`, `CreatePolicy`, `PutRolePolicy`, `DetachRolePolicy`, `DeleteUserPolicy` | 1 occurrence | +| 2 | Console sign-in without MFA | Identity & access management | CloudTrail → CW Logs → metric filter → CW Alarm → SNS | CIS 4.2: `{ ($.eventName = "ConsoleLogin") && ($.additionalEventData.MFAUsed != "Yes") }` | ≥ 1 in 5 min | +| 3 | Root account usage | Identity & access management | CloudTrail → CW Logs → metric filter → CW Alarm → SNS | CIS 4.3: `{ $.userIdentity.type = "Root" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != "AwsServiceEvent" }` | ≥ 1 in 5 min | +| 4 | IAM policy changed | Identity & access management | CloudTrail → CW Logs → metric filter → CW Alarm → SNS | CIS 4.4: the full 16-event IAM policy mutation list (`Put*Policy`, `Attach*`/`Detach*`, `CreatePolicy(Version)`, `DeletePolicy(Version)`, `Delete*Policy`) | ≥ 1 in 5 min | | 5 | Security group rule changed | Infrastructure security | EventBridge → SNS | `eventName`: `AuthorizeSecurityGroupIngress`, `RevokeSecurityGroupIngress`, `AuthorizeSecurityGroupEgress`, `RevokeSecurityGroupEgress` | 1 occurrence | | 6 | AccessDenied spike | Security monitoring & response | CloudTrail → CW Logs → metric filter → CW Alarm → SNS | `{ ($.errorCode = "AccessDenied") \|\| ($.errorCode = "*UnauthorizedOperation") }` | > 5 in 5 min | | 7 | KMS CMK disabled or deletion scheduled | Data security (at rest) | EventBridge → SNS | `source: aws.kms`, `eventName`: `DisableKey`, `ScheduleKeyDeletion`, `CancelKeyDeletion` | 1 occurrence | | 8 | Anomalous GetSecretValue | Identity & access management | EventBridge → SNS | `source: aws.secretsmanager`, `eventName`: `GetSecretValue` | 1 occurrence | | 9 | Detection rule tampered | Security monitoring & response | EventBridge → SNS | `source: aws.events`, `eventName`: `DeleteRule`, `DisableRule`, `RemoveTargets` | 1 occurrence | +**Rules 2–4** -- CIS-verbatim metric filters (see Region placement). Rule 2 is expected-but-logged +for IAM Identity Center sign-ins: the `ConsoleLogin` event records `MFAUsed: No` for federated +sessions because MFA happens at the IdP, so every SSO console login fires it — same triage posture +as rule 8 (acknowledge your own sign-ins; investigate any others). Rule 3's CIS pattern excludes +AWS-service-initiated root events (`invokedBy`/`AwsServiceEvent`), which the old EventBridge +pattern would have alerted on. Rule 4 covers the full 16-event CIS list — the old pattern was +missing `DeletePolicy` and `DeletePolicyVersion`. + **Rule 6** -- AccessDenied spike is the fingerprint of permission enumeration (Pacu, ScoutSuite). One AccessDenied is noise; a burst is an attacker probing what a compromised credential can reach. **Rule 7** -- `CancelKeyDeletion` is included because cancelling a deletion implies a deletion was previously scheduled; the pair is itself suspicious and must be investigated. No keyId scoping: at LoonVault's scale (one CMK) any KMS lifecycle event is anomalous, and scoping to a specific ARN would silently miss the alert after every `destroy`/`apply` that recreates the key. **Rule 8** -- Zero-standing-baseline signal. No automated, always-on principal holds `secretsmanager:GetSecretValue`: no Lambda execution role has it, and the only Secrets Manager secret is the RDS-managed master password. Every `GetSecretValue` event therefore resolves to one of three things: (a) RDS-managed rotation (service principal `rds.amazonaws.com`), (b) a deliberate, rare operator maintenance action -- `db-init` on every stack bring-up (ADR-0008) or the documented cloud-exit export, both run with the operator's own credentials -- or (c) a compromise. The rule fires on all three (expected-but-logged): the operator acknowledges (a) and (b) and investigates anything else. See runbook for the triage procedure. -**Rule 9** -- Watches for suppression-class operations: deletion, disabling, or target removal from EventBridge rules. `PutRule` is deliberately excluded (every `loonvault-apply` fires it, creating noise). Residual limitation: rule 9 cannot detect its own deletion (self-referential detection problem); production fix is a dedicated monitoring account with cross-account CloudTrail delivery. +**Rule 9** -- Watches for suppression-class operations across both detection transports: deletion, disabling, or target removal on EventBridge rules (`DeleteRule`, `DisableRule`, `RemoveTargets`) and on the metric-filter alarms (`DeleteMetricFilter`, `DeleteAlarms`, `DisableAlarmActions`). `PutRule`/`PutMetricFilter` are deliberately excluded (every `loonvault-apply` fires them, creating noise). Residual limitation: rule 9 cannot detect its own deletion (self-referential detection problem); production fix is a dedicated monitoring account with cross-account CloudTrail delivery. --- @@ -470,6 +483,15 @@ answer its questions out loud, unaided. the one a production deployment would raise to >= 2 years (alongside Residual Risk #7's relocation to a Log Archive account). Each short-retention log group is tagged in code with a `CKV_AWS_338` Checkov skip citing this residual, so the gap is explicit, not silent. +9. **Global-service detections alert in minutes, not seconds.** Rules 2/3/4 (no-MFA sign-in, + root usage, IAM policy changes) run as CloudWatch metric-filter alarms because the + region-lock SCP forbids the us-east-1 EventBridge rules those global-service events would + need (ADR-0014). The path is CloudTrail → CW Logs delivery (~5 min) → alarm evaluation, + so expect **~5–10 minutes** from action to email versus seconds on the EventBridge rules. + Accepted: these are low-volume, any-occurrence-is-suspicious signals where the detection + guarantee matters more than latency, and the pattern is the CIS/Security Hub canonical one. + Production fix: a delegated-admin security account outside the SCP's scope hosting the + us-east-1 rules. ---