mirror of
https://github.com/ghndrx/terraform-foundation.git
synced 2026-02-10 23:04:59 +00:00
feat(security): add IAM Access Analyzer module
- External access analysis for S3, IAM, KMS, Lambda, SQS - Unused access analysis (optional) - identifies unused roles/keys/permissions - Archive rules for trusted org/accounts/principals - SNS notifications with email subscriptions - EventBridge integration for findings - CloudWatch alarm for new findings - Comprehensive README with usage examples
This commit is contained in:
179
terraform/modules/iam-access-analyzer/README.md
Normal file
179
terraform/modules/iam-access-analyzer/README.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# IAM Access Analyzer Module
|
||||
|
||||
Automatically analyzes resource policies to identify unintended external access to your AWS resources.
|
||||
|
||||
## Features
|
||||
|
||||
- **External Access Analysis**: Identifies public and cross-account access to:
|
||||
- S3 buckets
|
||||
- IAM roles
|
||||
- KMS keys
|
||||
- Lambda functions
|
||||
- SQS queues
|
||||
- Secrets Manager secrets
|
||||
|
||||
- **Unused Access Analysis** (optional): Identifies:
|
||||
- Unused IAM roles
|
||||
- Unused access keys
|
||||
- Unused permissions in policies
|
||||
|
||||
- **Archive Rules**: Suppress known-good access patterns:
|
||||
- Trusted organization
|
||||
- Trusted accounts
|
||||
- Trusted principals
|
||||
- Custom filter rules
|
||||
|
||||
- **Notifications**: Alert on new findings via:
|
||||
- SNS email notifications
|
||||
- EventBridge rules
|
||||
- CloudWatch alarms
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Account-Level Analyzer
|
||||
|
||||
```hcl
|
||||
module "access_analyzer" {
|
||||
source = "../modules/iam-access-analyzer"
|
||||
|
||||
name = "my-account-analyzer"
|
||||
type = "ACCOUNT"
|
||||
}
|
||||
```
|
||||
|
||||
### Organization-Level Analyzer
|
||||
|
||||
```hcl
|
||||
module "access_analyzer" {
|
||||
source = "../modules/iam-access-analyzer"
|
||||
|
||||
name = "org-analyzer"
|
||||
type = "ORGANIZATION"
|
||||
|
||||
# Trust your organization
|
||||
archive_trusted_organization = "o-xxxxxxxxxx"
|
||||
}
|
||||
```
|
||||
|
||||
### With Unused Access Analysis
|
||||
|
||||
```hcl
|
||||
module "access_analyzer" {
|
||||
source = "../modules/iam-access-analyzer"
|
||||
|
||||
name = "comprehensive-analyzer"
|
||||
type = "ACCOUNT"
|
||||
enable_unused_access = true
|
||||
unused_access_age_days = 90
|
||||
}
|
||||
```
|
||||
|
||||
### With Notifications
|
||||
|
||||
```hcl
|
||||
module "access_analyzer" {
|
||||
source = "../modules/iam-access-analyzer"
|
||||
|
||||
name = "monitored-analyzer"
|
||||
type = "ACCOUNT"
|
||||
enable_sns_notifications = true
|
||||
notification_emails = ["security@example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
### With Trusted Accounts
|
||||
|
||||
```hcl
|
||||
module "access_analyzer" {
|
||||
source = "../modules/iam-access-analyzer"
|
||||
|
||||
name = "multi-account-analyzer"
|
||||
type = "ACCOUNT"
|
||||
|
||||
archive_trusted_accounts = [
|
||||
"111111111111", # Shared services
|
||||
"222222222222", # Security account
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Archive Rules
|
||||
|
||||
```hcl
|
||||
module "access_analyzer" {
|
||||
source = "../modules/iam-access-analyzer"
|
||||
|
||||
name = "custom-analyzer"
|
||||
type = "ACCOUNT"
|
||||
|
||||
archive_rules = {
|
||||
s3_cloudfront = {
|
||||
description = "Allow CloudFront OAI access to S3"
|
||||
filter_criteria = [
|
||||
{
|
||||
criterion = "resourceType"
|
||||
eq = ["AWS::S3::Bucket"]
|
||||
},
|
||||
{
|
||||
criterion = "principal.AWS"
|
||||
contains = ["arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Variables
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `name` | string | `"default-analyzer"` | Access Analyzer name |
|
||||
| `type` | string | `"ACCOUNT"` | Analyzer type: ACCOUNT or ORGANIZATION |
|
||||
| `enable_unused_access` | bool | `false` | Enable unused access analyzer |
|
||||
| `unused_access_age_days` | number | `90` | Days before flagging unused access |
|
||||
| `enable_sns_notifications` | bool | `false` | Create SNS topic for findings |
|
||||
| `sns_topic_arn` | string | `""` | Existing SNS topic ARN |
|
||||
| `notification_emails` | list(string) | `[]` | Email addresses for notifications |
|
||||
| `archive_trusted_organization` | string | `""` | Organization ID to trust |
|
||||
| `archive_trusted_accounts` | list(string) | `[]` | Account IDs to trust |
|
||||
| `archive_trusted_principals` | list(string) | `[]` | Principal ARNs to trust |
|
||||
| `archive_rules` | map(object) | `{}` | Custom archive rules |
|
||||
| `enable_eventbridge` | bool | `false` | Enable EventBridge rule |
|
||||
| `tags` | map(string) | `{}` | Resource tags |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
| `analyzer_arn` | Access Analyzer ARN |
|
||||
| `analyzer_id` | Access Analyzer ID |
|
||||
| `analyzer_name` | Access Analyzer name |
|
||||
| `unused_access_analyzer_arn` | Unused Access Analyzer ARN |
|
||||
| `sns_topic_arn` | SNS topic ARN for notifications |
|
||||
| `eventbridge_rule_arn` | EventBridge rule ARN |
|
||||
| `archive_rules` | Created archive rules |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with ACCOUNT type** - Easier to manage, no org admin required
|
||||
2. **Enable notifications** - Don't let findings go unnoticed
|
||||
3. **Review findings weekly** - Prioritize PUBLIC access findings
|
||||
4. **Archive known-good patterns** - Reduce noise from trusted cross-account access
|
||||
5. **Enable unused access** - Identify over-privileged roles and users
|
||||
|
||||
## Finding Types
|
||||
|
||||
| Type | Severity | Description |
|
||||
|------|----------|-------------|
|
||||
| `PublicAccess` | Critical | Resource is publicly accessible |
|
||||
| `CrossAccountAccess` | High | External account has access |
|
||||
| `UnusedIAMRole` | Medium | IAM role hasn't been used |
|
||||
| `UnusedPermission` | Medium | Permission hasn't been used |
|
||||
| `UnusedAccessKey` | Medium | Access key hasn't been used |
|
||||
|
||||
## Related Modules
|
||||
|
||||
- `security-hub` - Aggregates Access Analyzer findings
|
||||
- `config-rules` - Compliance monitoring
|
||||
- `guardduty` - Threat detection
|
||||
403
terraform/modules/iam-access-analyzer/main.tf
Normal file
403
terraform/modules/iam-access-analyzer/main.tf
Normal file
@@ -0,0 +1,403 @@
|
||||
################################################################################
|
||||
# IAM Access Analyzer Module
|
||||
#
|
||||
# Analyzes resource policies to identify unintended external access:
|
||||
# - S3 buckets, IAM roles, KMS keys, Lambda functions, SQS queues
|
||||
# - Findings for public/cross-account access
|
||||
# - Archive rules for known-good patterns
|
||||
# - SNS notifications for new findings
|
||||
# - Unused access analysis (optional)
|
||||
#
|
||||
# Usage:
|
||||
# module "access_analyzer" {
|
||||
# source = "../modules/iam-access-analyzer"
|
||||
#
|
||||
# name = "organization-analyzer"
|
||||
# type = "ORGANIZATION" # or "ACCOUNT"
|
||||
#
|
||||
# enable_unused_access = true
|
||||
# unused_access_age = 90
|
||||
#
|
||||
# archive_rules = {
|
||||
# trusted_org = {
|
||||
# filter_type = "AWS::IAM::Role"
|
||||
# principal_org_id = "o-xxxxxxxxxx"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
################################################################################
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
default = "default-analyzer"
|
||||
description = "Access Analyzer name"
|
||||
}
|
||||
|
||||
variable "type" {
|
||||
type = string
|
||||
default = "ACCOUNT"
|
||||
description = "Analyzer type: ACCOUNT or ORGANIZATION (org requires delegated admin)"
|
||||
validation {
|
||||
condition = contains(["ACCOUNT", "ORGANIZATION", "ACCOUNT_UNUSED_ACCESS", "ORGANIZATION_UNUSED_ACCESS"], var.type)
|
||||
error_message = "Type must be ACCOUNT, ORGANIZATION, ACCOUNT_UNUSED_ACCESS, or ORGANIZATION_UNUSED_ACCESS"
|
||||
}
|
||||
}
|
||||
|
||||
variable "enable_unused_access" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable unused access analyzer (identifies unused permissions)"
|
||||
}
|
||||
|
||||
variable "unused_access_age_days" {
|
||||
type = number
|
||||
default = 90
|
||||
description = "Days of inactivity before flagging unused access"
|
||||
}
|
||||
|
||||
variable "enable_sns_notifications" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Create SNS topic for Access Analyzer findings"
|
||||
}
|
||||
|
||||
variable "sns_topic_arn" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Existing SNS topic ARN for notifications (creates one if empty and enabled)"
|
||||
}
|
||||
|
||||
variable "notification_emails" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Email addresses to notify for new findings"
|
||||
}
|
||||
|
||||
variable "archive_rules" {
|
||||
type = map(object({
|
||||
description = optional(string, "")
|
||||
filter_criteria = list(object({
|
||||
criterion = string
|
||||
values = list(string)
|
||||
exists = optional(bool)
|
||||
eq = optional(list(string))
|
||||
neq = optional(list(string))
|
||||
contains = optional(list(string))
|
||||
}))
|
||||
}))
|
||||
default = {}
|
||||
description = "Archive rules for known-good access patterns"
|
||||
}
|
||||
|
||||
# Pre-built archive rule templates
|
||||
variable "archive_trusted_organization" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Organization ID to trust (auto-creates archive rule)"
|
||||
}
|
||||
|
||||
variable "archive_trusted_accounts" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Account IDs to trust (auto-creates archive rule)"
|
||||
}
|
||||
|
||||
variable "archive_trusted_principals" {
|
||||
type = list(string)
|
||||
default = []
|
||||
description = "Principal ARNs to trust (auto-creates archive rule)"
|
||||
}
|
||||
|
||||
variable "enable_eventbridge" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable EventBridge rule for findings"
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
type = map(string)
|
||||
default = {}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Data Sources
|
||||
################################################################################
|
||||
|
||||
data "aws_caller_identity" "current" {}
|
||||
data "aws_region" "current" {}
|
||||
data "aws_partition" "current" {}
|
||||
|
||||
################################################################################
|
||||
# Access Analyzer
|
||||
################################################################################
|
||||
|
||||
resource "aws_accessanalyzer_analyzer" "main" {
|
||||
analyzer_name = var.name
|
||||
type = var.type
|
||||
|
||||
tags = merge(var.tags, { Name = var.name })
|
||||
}
|
||||
|
||||
# Unused Access Analyzer (separate analyzer with different type)
|
||||
resource "aws_accessanalyzer_analyzer" "unused_access" {
|
||||
count = var.enable_unused_access ? 1 : 0
|
||||
|
||||
analyzer_name = "${var.name}-unused-access"
|
||||
type = var.type == "ORGANIZATION" ? "ORGANIZATION_UNUSED_ACCESS" : "ACCOUNT_UNUSED_ACCESS"
|
||||
|
||||
configuration {
|
||||
unused_access {
|
||||
unused_access_age = var.unused_access_age_days
|
||||
}
|
||||
}
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-unused-access" })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Archive Rules
|
||||
################################################################################
|
||||
|
||||
# Archive rule for trusted organization
|
||||
resource "aws_accessanalyzer_archive_rule" "trusted_org" {
|
||||
count = var.archive_trusted_organization != "" ? 1 : 0
|
||||
|
||||
analyzer_name = aws_accessanalyzer_analyzer.main.analyzer_name
|
||||
rule_name = "trusted-organization"
|
||||
|
||||
filter {
|
||||
criteria = "principal.AWS"
|
||||
contains = ["arn:${data.aws_partition.current.partition}:iam::*:root"]
|
||||
}
|
||||
|
||||
filter {
|
||||
criteria = "condition.aws:PrincipalOrgID"
|
||||
eq = [var.archive_trusted_organization]
|
||||
}
|
||||
}
|
||||
|
||||
# Archive rules for trusted accounts
|
||||
resource "aws_accessanalyzer_archive_rule" "trusted_accounts" {
|
||||
count = length(var.archive_trusted_accounts) > 0 ? 1 : 0
|
||||
|
||||
analyzer_name = aws_accessanalyzer_analyzer.main.analyzer_name
|
||||
rule_name = "trusted-accounts"
|
||||
|
||||
filter {
|
||||
criteria = "principal.AWS"
|
||||
contains = [for acc in var.archive_trusted_accounts : "arn:${data.aws_partition.current.partition}:iam::${acc}:root"]
|
||||
}
|
||||
}
|
||||
|
||||
# Archive rules for trusted principals
|
||||
resource "aws_accessanalyzer_archive_rule" "trusted_principals" {
|
||||
count = length(var.archive_trusted_principals) > 0 ? 1 : 0
|
||||
|
||||
analyzer_name = aws_accessanalyzer_analyzer.main.analyzer_name
|
||||
rule_name = "trusted-principals"
|
||||
|
||||
filter {
|
||||
criteria = "principal.AWS"
|
||||
eq = var.archive_trusted_principals
|
||||
}
|
||||
}
|
||||
|
||||
# Custom archive rules
|
||||
resource "aws_accessanalyzer_archive_rule" "custom" {
|
||||
for_each = var.archive_rules
|
||||
|
||||
analyzer_name = aws_accessanalyzer_analyzer.main.analyzer_name
|
||||
rule_name = each.key
|
||||
|
||||
dynamic "filter" {
|
||||
for_each = each.value.filter_criteria
|
||||
content {
|
||||
criteria = filter.value.criterion
|
||||
eq = lookup(filter.value, "eq", null)
|
||||
neq = lookup(filter.value, "neq", null)
|
||||
contains = lookup(filter.value, "contains", null)
|
||||
exists = lookup(filter.value, "exists", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# SNS Topic for Notifications
|
||||
################################################################################
|
||||
|
||||
resource "aws_sns_topic" "findings" {
|
||||
count = var.enable_sns_notifications && var.sns_topic_arn == "" ? 1 : 0
|
||||
|
||||
name = "${var.name}-access-analyzer-findings"
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-access-analyzer-findings" })
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_policy" "findings" {
|
||||
count = var.enable_sns_notifications && var.sns_topic_arn == "" ? 1 : 0
|
||||
|
||||
arn = aws_sns_topic.findings[0].arn
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Sid = "AllowEventBridgePublish"
|
||||
Effect = "Allow"
|
||||
Principal = {
|
||||
Service = "events.amazonaws.com"
|
||||
}
|
||||
Action = "sns:Publish"
|
||||
Resource = aws_sns_topic.findings[0].arn
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_sns_topic_subscription" "email" {
|
||||
count = var.enable_sns_notifications && var.sns_topic_arn == "" ? length(var.notification_emails) : 0
|
||||
|
||||
topic_arn = aws_sns_topic.findings[0].arn
|
||||
protocol = "email"
|
||||
endpoint = var.notification_emails[count.index]
|
||||
}
|
||||
|
||||
locals {
|
||||
sns_topic_arn = var.enable_sns_notifications ? (var.sns_topic_arn != "" ? var.sns_topic_arn : aws_sns_topic.findings[0].arn) : ""
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# EventBridge Rule for Findings
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_event_rule" "findings" {
|
||||
count = var.enable_eventbridge || var.enable_sns_notifications ? 1 : 0
|
||||
|
||||
name = "${var.name}-access-analyzer-findings"
|
||||
description = "Capture IAM Access Analyzer findings"
|
||||
|
||||
event_pattern = jsonencode({
|
||||
source = ["aws.access-analyzer"]
|
||||
detail-type = ["Access Analyzer Finding"]
|
||||
detail = {
|
||||
status = ["ACTIVE"]
|
||||
}
|
||||
})
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-access-analyzer-findings" })
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_event_target" "sns" {
|
||||
count = var.enable_sns_notifications ? 1 : 0
|
||||
|
||||
rule = aws_cloudwatch_event_rule.findings[0].name
|
||||
target_id = "sns-notification"
|
||||
arn = local.sns_topic_arn
|
||||
|
||||
input_transformer {
|
||||
input_paths = {
|
||||
finding_id = "$.detail.id"
|
||||
resource = "$.detail.resource"
|
||||
resource_type = "$.detail.resourceType"
|
||||
principal = "$.detail.principal"
|
||||
action = "$.detail.action"
|
||||
condition = "$.detail.condition"
|
||||
finding_type = "$.detail.findingType"
|
||||
status = "$.detail.status"
|
||||
account = "$.account"
|
||||
region = "$.region"
|
||||
}
|
||||
input_template = <<-EOF
|
||||
{
|
||||
"summary": "IAM Access Analyzer Finding",
|
||||
"finding_id": "<finding_id>",
|
||||
"resource": "<resource>",
|
||||
"resource_type": "<resource_type>",
|
||||
"principal": "<principal>",
|
||||
"actions": "<action>",
|
||||
"condition": "<condition>",
|
||||
"finding_type": "<finding_type>",
|
||||
"status": "<status>",
|
||||
"account": "<account>",
|
||||
"region": "<region>"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# CloudWatch Metrics (optional)
|
||||
################################################################################
|
||||
|
||||
resource "aws_cloudwatch_metric_alarm" "new_findings" {
|
||||
count = var.enable_sns_notifications ? 1 : 0
|
||||
|
||||
alarm_name = "${var.name}-access-analyzer-new-findings"
|
||||
comparison_operator = "GreaterThanThreshold"
|
||||
evaluation_periods = 1
|
||||
metric_name = "ActiveFindings"
|
||||
namespace = "Custom/AccessAnalyzer"
|
||||
period = 300
|
||||
statistic = "Maximum"
|
||||
threshold = 0
|
||||
alarm_description = "Alert when new IAM Access Analyzer findings are detected"
|
||||
treat_missing_data = "notBreaching"
|
||||
|
||||
alarm_actions = [local.sns_topic_arn]
|
||||
ok_actions = []
|
||||
|
||||
tags = merge(var.tags, { Name = "${var.name}-access-analyzer-alarm" })
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Outputs
|
||||
################################################################################
|
||||
|
||||
output "analyzer_arn" {
|
||||
value = aws_accessanalyzer_analyzer.main.arn
|
||||
description = "Access Analyzer ARN"
|
||||
}
|
||||
|
||||
output "analyzer_id" {
|
||||
value = aws_accessanalyzer_analyzer.main.id
|
||||
description = "Access Analyzer ID"
|
||||
}
|
||||
|
||||
output "analyzer_name" {
|
||||
value = aws_accessanalyzer_analyzer.main.analyzer_name
|
||||
description = "Access Analyzer name"
|
||||
}
|
||||
|
||||
output "unused_access_analyzer_arn" {
|
||||
value = var.enable_unused_access ? aws_accessanalyzer_analyzer.unused_access[0].arn : null
|
||||
description = "Unused Access Analyzer ARN"
|
||||
}
|
||||
|
||||
output "sns_topic_arn" {
|
||||
value = local.sns_topic_arn != "" ? local.sns_topic_arn : null
|
||||
description = "SNS topic ARN for findings notifications"
|
||||
}
|
||||
|
||||
output "eventbridge_rule_arn" {
|
||||
value = var.enable_eventbridge || var.enable_sns_notifications ? aws_cloudwatch_event_rule.findings[0].arn : null
|
||||
description = "EventBridge rule ARN for findings"
|
||||
}
|
||||
|
||||
output "archive_rules" {
|
||||
value = {
|
||||
trusted_org = var.archive_trusted_organization != "" ? "trusted-organization" : null
|
||||
trusted_accounts = length(var.archive_trusted_accounts) > 0 ? "trusted-accounts" : null
|
||||
trusted_principals = length(var.archive_trusted_principals) > 0 ? "trusted-principals" : null
|
||||
custom = keys(var.archive_rules)
|
||||
}
|
||||
description = "Created archive rules"
|
||||
}
|
||||
Reference in New Issue
Block a user