diff --git a/Backend/infrastructure/docs/compliance-hub.md b/Backend/infrastructure/docs/compliance-hub.md new file mode 100644 index 00000000..470e80fd --- /dev/null +++ b/Backend/infrastructure/docs/compliance-hub.md @@ -0,0 +1,165 @@ +# AWS Security Hub — Compliance Aggregation + +Central hub for security findings across the GistPin AWS environment. Implements +enablement, the CIS AWS Foundations Benchmark, cross-region finding +aggregation, automated remediation for common findings, and a weekly +compliance report. + +Related issue: `#757 Setup AWS Security Hub for compliance aggregation` + +## Architecture + +``` + ┌─────────────────────────┐ + │ AWS Security Hub │ + │ (CIS + FSBP standards) │ + └───────────┬──────────────┘ + │ findings + ┌───────────────┼───────────────────┐ + │ │ + EventBridge rule EventBridge schedule + (finding imported, matches (weekly, Mon 08:00 UTC) + remediation allow-list) │ + │ ▼ + ▼ compliance-report Lambda + remediation Lambda (queries findings, writes + (fixes issue, updates finding, report to S3, notifies SNS) + notifies SNS) │ + │ ▼ + ▼ S3: compliance report bucket + SNS: security-hub-remediation-alerts (email) +``` + +Finding aggregation is enabled via `aws_securityhub_finding_aggregator`, which +consolidates findings from all linked regions into the home region so a +single Security Hub view reflects the account's full posture. + +## Components + +| Component | File | Purpose | +|---|---|---| +| Terraform module | `infrastructure/terraform/security-hub.tf` | Enables Security Hub, subscribes to standards, sets up aggregation, remediation Lambda + EventBridge rule, and the weekly report Lambda + schedule. | +| Remediation Lambda | `infrastructure/lambda/remediation/index.py` | Automated fix-on-finding, triggered by EventBridge. | +| Compliance report Lambda | `infrastructure/lambda/compliance-report/index.py` | Weekly summary of active findings, written to S3 and emailed via SNS. | +| CLI remediation script | `infrastructure/scripts/remediate-findings.sh` | Manual/SSM-runnable equivalent of the remediation Lambda, for ad-hoc fixes, dry runs, or environments without the Lambda wired up. | + +## Standards enabled + +- **CIS AWS Foundations Benchmark v1.4.0** — baseline hardening checks (root + account usage, IAM password policy, CloudTrail, VPC flow logs, etc). +- **AWS Foundational Security Best Practices (FSBP) v1.0.0** — broader + service-level checks; several auto-remediation rules below depend on FSBP + control IDs (e.g. `S3.8`, `EC2.19`). + +Toggle CIS with the `enable_cis_standard` Terraform variable; FSBP is always +subscribed since remediation depends on it. + +## Finding aggregation + +`aws_securityhub_finding_aggregator` is configured with `linking_mode = +"ALL_REGIONS"` by default. Set `linked_regions` to a specific list to +restrict aggregation to named regions instead (useful if the account only +operates in 2–3 regions and you want to avoid noise from unused ones). + +## Automated remediation + +The remediation Lambda subscribes to `Security Hub Findings - Imported` +events, filtered to `RecordState = ACTIVE`, `Workflow.Status IN (NEW, +NOTIFIED)`, and a `GeneratorId` allow-list (`remediation_finding_types` +variable). Currently supported: + +| Finding | Action taken | +|---|---| +| `FSBP S3.8` — S3 bucket allows public access | Applies `PutPublicAccessBlock` with all four blocks enabled. | +| `CIS 2.1.1` — S3 bucket not encrypted | Enables default SSE-S3 (`AES256`) bucket encryption. | +| `FSBP EC2.19` — Security group open to the world on a high-risk port | Revokes the `0.0.0.0/0` ingress rule for ports 22, 3389, 3306, 5432, 1433, 27017. | +| `CIS 1.12` / `FSBP IAM.6` — Root account usage / no hardware MFA on root | **Not auto-fixed.** Escalated via SNS for manual review — root-account changes are intentionally excluded from automation. | + +Design principles: +- **Allow-list only.** Only `GeneratorId`s explicitly listed in + `remediation_finding_types` trigger automation; everything else is left for + manual triage in Security Hub. +- **Idempotent fixes.** Each remediation is safe to re-run (e.g. re-blocking + public access on an already-blocked bucket is a no-op). +- **No destructive account-level changes.** Root-account and IAM-policy + findings are escalated, never auto-modified. +- **Audit trail.** Every automated fix updates the finding's `Workflow.Status` + to `RESOLVED` with a `Note` describing the action taken, and posts to the + `security-hub-remediation-alerts` SNS topic. + +To run the same fixes manually or via SSM Run Command: + +```bash +./infrastructure/scripts/remediate-findings.sh --list +./infrastructure/scripts/remediate-findings.sh --scan --dry-run +./infrastructure/scripts/remediate-findings.sh --finding-type s3-public-access --bucket my-bucket +``` + +## Weekly compliance report + +Runs every **Monday 08:00 UTC** (`compliance_report_schedule` variable). The +Lambda: + +1. Pulls all `ACTIVE` findings with `Workflow.Status IN (NEW, NOTIFIED)`. +2. Summarizes by severity, compliance status, and standard. +3. Writes `reports//summary.json` and `reports//summary.md` to the + `report_bucket_name` S3 bucket (encrypted at rest, public access blocked, + 365-day retention). +4. Publishes a short summary + S3 link to the `security-hub-remediation-alerts` + SNS topic (email subscription configured via `notification_email`). + +## Deployment + +```bash +cd infrastructure/terraform +terraform init +terraform plan \ + -var="notification_email=security-team@example.com" \ + -var="report_bucket_name=gistpin-security-hub-reports" +terraform apply \ + -var="notification_email=security-team@example.com" \ + -var="report_bucket_name=gistpin-security-hub-reports" +``` + +Confirm the SNS email subscription after the first apply (AWS sends a +confirmation link to `notification_email`). + +## Variables reference + +| Variable | Default | Description | +|---|---|---| +| `aggregator_region` | `us-east-1` | Home region for the finding aggregator. | +| `linked_regions` | `[]` (all regions) | Regions to aggregate; empty = all. | +| `enable_cis_standard` | `true` | Toggle CIS AWS Foundations Benchmark. | +| `cis_standard_version` | `1.4.0` | CIS benchmark version. | +| `enable_auto_remediation` | `true` | Toggle the remediation Lambda + EventBridge rule. | +| `remediation_finding_types` | see `.tf` | GeneratorId allow-list for auto-remediation. | +| `compliance_report_schedule` | `cron(0 8 ? * MON *)` | Weekly report schedule. | +| `notification_email` | — (required) | Destination for SNS alerts/reports. | +| `report_bucket_name` | — (required) | S3 bucket for compliance reports. | + +## Extending remediation coverage + +To add a new auto-remediated finding type: + +1. Add the `GeneratorId` to `remediation_finding_types` in + `security-hub.tf`. +2. Add a matching handler in `infrastructure/lambda/remediation/index.py` + (decorate with `@remediates("")`), and grant any new IAM + permissions it needs in `aws_iam_role_policy.remediation_lambda`. +3. Add the equivalent manual fix function to + `infrastructure/scripts/remediate-findings.sh` so operators can run it + outside the Lambda path. +4. Test with `--dry-run` against a non-production account before enabling + the EventBridge rule in production. + +## Known limitations / follow-ups + +- Cross-**account** aggregation (via Security Hub administrator/member + accounts or AWS Organizations) is not yet wired up — this module covers + cross-*region* aggregation within a single account. Tracked as a follow-up. +- The weekly report is a flat summary; trend-over-time comparison would + require persisting historical summaries (e.g. via Athena over the S3 + reports) — not yet implemented. +- Auto-remediation currently covers 3 finding types; expand the allow-list + incrementally as each new remediation is reviewed and tested. \ No newline at end of file diff --git a/Backend/infrastructure/lambda/compliance-report/index.py b/Backend/infrastructure/lambda/compliance-report/index.py new file mode 100644 index 00000000..2c369392 --- /dev/null +++ b/Backend/infrastructure/lambda/compliance-report/index.py @@ -0,0 +1,106 @@ +""" +security-hub-weekly-compliance-report +Triggered weekly by EventBridge. Pulls active Security Hub findings, +summarizes compliance posture (by standard, severity, and status), +writes a JSON + Markdown report to S3, and publishes a short summary to SNS. +""" +import datetime +import json +import os +import boto3 + +securityhub = boto3.client("securityhub") +s3 = boto3.client("s3") +sns = boto3.client("sns") + +REPORT_BUCKET = os.environ["REPORT_BUCKET"] +SNS_TOPIC_ARN = os.environ.get("SNS_TOPIC_ARN") + + +def get_active_findings(): + findings = [] + paginator = securityhub.get_paginator("get_findings") + for page in paginator.paginate( + Filters={ + "RecordState": [{"Value": "ACTIVE", "Comparison": "EQUALS"}], + "WorkflowStatus": [ + {"Value": "NEW", "Comparison": "EQUALS"}, + {"Value": "NOTIFIED", "Comparison": "EQUALS"}, + ], + } + ): + findings.extend(page["Findings"]) + return findings + + +def summarize(findings): + by_severity = {} + by_standard = {} + by_status = {} + + for f in findings: + sev = f.get("Severity", {}).get("Label", "UNKNOWN") + by_severity[sev] = by_severity.get(sev, 0) + 1 + + status = f.get("Compliance", {}).get("Status", "UNKNOWN") + by_status[status] = by_status.get(status, 0) + 1 + + for standard in f.get("Compliance", {}).get("AssociatedStandards", []): + sid = standard.get("StandardsId", "unknown-standard") + by_standard[sid] = by_standard.get(sid, 0) + 1 + + return { + "total_active_findings": len(findings), + "by_severity": by_severity, + "by_compliance_status": by_status, + "by_standard": by_standard, + } + + +def render_markdown(summary, week_of): + lines = [ + f"# Weekly Security Hub Compliance Report — {week_of}", + "", + f"**Total active findings:** {summary['total_active_findings']}", + "", + "## By Severity", + ] + for sev, count in sorted(summary["by_severity"].items()): + lines.append(f"- {sev}: {count}") + + lines += ["", "## By Compliance Status"] + for status, count in sorted(summary["by_compliance_status"].items()): + lines.append(f"- {status}: {count}") + + lines += ["", "## By Standard"] + for standard, count in sorted(summary["by_standard"].items()): + lines.append(f"- {standard}: {count}") + + return "\n".join(lines) + "\n" + + +def handler(event, context): + week_of = datetime.date.today().isoformat() + findings = get_active_findings() + summary = summarize(findings) + markdown = render_markdown(summary, week_of) + + json_key = f"reports/{week_of}/summary.json" + md_key = f"reports/{week_of}/summary.md" + + s3.put_object(Bucket=REPORT_BUCKET, Key=json_key, Body=json.dumps(summary, indent=2)) + s3.put_object(Bucket=REPORT_BUCKET, Key=md_key, Body=markdown) + + if SNS_TOPIC_ARN: + sns.publish( + TopicArn=SNS_TOPIC_ARN, + Subject=f"Weekly Security Hub Compliance Report — {week_of}", + Message=( + f"Compliance report for {week_of} generated.\n\n" + f"Total active findings: {summary['total_active_findings']}\n" + f"By severity: {summary['by_severity']}\n\n" + f"Full report: s3://{REPORT_BUCKET}/{md_key}" + ), + ) + + return {"bucket": REPORT_BUCKET, "json_key": json_key, "md_key": md_key, "summary": summary} \ No newline at end of file diff --git a/Backend/infrastructure/lambda/remediation/index.py b/Backend/infrastructure/lambda/remediation/index.py new file mode 100644 index 00000000..b5e92df8 --- /dev/null +++ b/Backend/infrastructure/lambda/remediation/index.py @@ -0,0 +1,126 @@ +""" +security-hub-auto-remediation +Triggered by EventBridge on "Security Hub Findings - Imported" events for a +configured allow-list of GeneratorIds. Applies a safe, idempotent fix for +each supported finding type, then updates the finding's workflow status +and posts a summary to SNS. + +Mirrors the logic in infrastructure/scripts/remediate-findings.sh so the same +remediation can be run ad-hoc (CLI) or automatically (Lambda). +""" +import json +import os +import boto3 + +securityhub = boto3.client("securityhub") +s3 = boto3.client("s3") +ec2 = boto3.client("ec2") +iam = boto3.client("iam") +sns = boto3.client("sns") + +SNS_TOPIC_ARN = os.environ.get("SNS_TOPIC_ARN") + +# Maps a Security Hub GeneratorId (or its suffix) to a remediation function. +REMEDIATORS = {} + + +def remediates(*ids): + def wrap(fn): + for i in ids: + REMEDIATORS[i] = fn + return fn + return wrap + + +@remediates("aws-foundational-security-best-practices/v/1.0.0/S3.8") +def fix_s3_public_access(finding): + """Block public access on any S3 bucket flagged for public access.""" + for resource in finding.get("Resources", []): + if resource.get("Type") != "AwsS3Bucket": + continue + bucket = resource["Details"]["AwsS3Bucket"].get("Name") or resource["Id"].split(":::")[-1] + s3.put_public_access_block( + Bucket=bucket, + PublicAccessBlockConfiguration={ + "BlockPublicAcls": True, + "IgnorePublicAcls": True, + "BlockPublicPolicy": True, + "RestrictPublicBuckets": True, + }, + ) + yield f"Blocked public access on s3://{bucket}" + + +@remediates("cis-aws-foundations-benchmark/v/1.4.0/2.1.1") +def fix_s3_encryption(finding): + """Enforce default SSE-S3 encryption on flagged buckets.""" + for resource in finding.get("Resources", []): + if resource.get("Type") != "AwsS3Bucket": + continue + bucket = resource["Id"].split(":::")[-1] + s3.put_bucket_encryption( + Bucket=bucket, + ServerSideEncryptionConfiguration={ + "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}] + }, + ) + yield f"Enabled default encryption on s3://{bucket}" + + +@remediates("aws-foundational-security-best-practices/v/1.0.0/EC2.19") +def fix_open_security_group(finding): + """Revoke ingress rules that expose high-risk ports to 0.0.0.0/0 or ::/0.""" + high_risk_ports = {22, 3389, 3306, 5432, 1433, 27017} + for resource in finding.get("Resources", []): + if resource.get("Type") != "AwsEc2SecurityGroup": + continue + sg_id = resource["Id"].split("/")[-1] + sg = ec2.describe_security_groups(GroupIds=[sg_id])["SecurityGroups"][0] + for perm in sg.get("IpPermissions", []): + from_port = perm.get("FromPort") + open_ranges = [r for r in perm.get("IpRanges", []) if r.get("CidrIp") == "0.0.0.0/0"] + if open_ranges and (from_port is None or from_port in high_risk_ports): + revoke_perm = dict(perm) + revoke_perm["IpRanges"] = open_ranges + ec2.revoke_security_group_ingress(GroupId=sg_id, IpPermissions=[revoke_perm]) + yield f"Revoked 0.0.0.0/0 ingress on port {from_port} for {sg_id}" + + +@remediates("cis-aws-foundations-benchmark/v/1.4.0/1.12", "aws-foundational-security-best-practices/v/1.0.0/IAM.6") +def flag_root_account_issue(finding): + """Root-account findings are not safely auto-fixable; escalate instead.""" + yield "Root account finding requires manual review — escalated via SNS, no automated change applied." + + +def handler(event, context): + findings = event.get("detail", {}).get("findings", []) + results = [] + + for finding in findings: + generator_id = finding.get("GeneratorId", "") + remediator = REMEDIATORS.get(generator_id) + if not remediator: + continue + + actions_taken = list(remediator(finding)) + if actions_taken: + securityhub.batch_update_findings( + FindingIdentifiers=[ + {"Id": finding["Id"], "ProductArn": finding["ProductArn"]} + ], + Workflow={"Status": "RESOLVED"}, + Note={ + "Text": "Auto-remediated: " + "; ".join(actions_taken), + "UpdatedBy": "security-hub-auto-remediation", + }, + ) + results.extend(actions_taken) + + if results and SNS_TOPIC_ARN: + sns.publish( + TopicArn=SNS_TOPIC_ARN, + Subject="Security Hub: automated remediation applied", + Message=json.dumps({"remediations": results}, indent=2), + ) + + return {"remediations": results} \ No newline at end of file diff --git a/Backend/infrastructure/scripts/remediate-findings.sh b/Backend/infrastructure/scripts/remediate-findings.sh new file mode 100644 index 00000000..91a0f115 --- /dev/null +++ b/Backend/infrastructure/scripts/remediate-findings.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# remediate-findings.sh +# +# Ad-hoc / SSM-runnable remediation for common AWS Security Hub findings. +# Mirrors the automated logic in infrastructure/lambda/remediation/index.py +# so operators can run the same fixes manually, dry-run them, or invoke them +# via SSM Run Command outside of the EventBridge -> Lambda path. +# +# Usage: +# ./remediate-findings.sh --list +# ./remediate-findings.sh --finding-type s3-public-access --bucket my-bucket [--dry-run] +# ./remediate-findings.sh --finding-type s3-encryption --bucket my-bucket [--dry-run] +# ./remediate-findings.sh --finding-type open-security-group --sg-id sg-0123456789abcdef0 [--dry-run] +# ./remediate-findings.sh --scan # finds and remediates all supported ACTIVE findings automatically +# +# Requires: awscli v2, jq +set -euo pipefail + +DRY_RUN=false +FINDING_TYPE="" +BUCKET="" +SG_ID="" +HIGH_RISK_PORTS=(22 3389 3306 5432 1433 27017) + +usage() { + grep '^#' "$0" | sed -e 's/^#//' -e '1d' + exit 1 +} + +log() { echo "[remediate-findings] $*" >&2; } + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { log "ERROR: required command '$1' not found"; exit 1; } +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --finding-type) FINDING_TYPE="$2"; shift 2 ;; + --bucket) BUCKET="$2"; shift 2 ;; + --sg-id) SG_ID="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --scan) FINDING_TYPE="scan"; shift ;; + --list) FINDING_TYPE="list"; shift ;; + -h|--help) usage ;; + *) log "Unknown argument: $1"; usage ;; + esac + done +} + +run() { + if $DRY_RUN; then + log "DRY RUN: $*" + else + log "Executing: $*" + "$@" + fi +} + +fix_s3_public_access() { + local bucket="$1" + run aws s3api put-public-access-block \ + --bucket "$bucket" \ + --public-access-block-configuration \ + BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true + log "Blocked public access on s3://$bucket" +} + +fix_s3_encryption() { + local bucket="$1" + run aws s3api put-bucket-encryption \ + --bucket "$bucket" \ + --server-side-encryption-configuration \ + '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}' + log "Enabled default SSE-S3 encryption on s3://$bucket" +} + +fix_open_security_group() { + local sg_id="$1" + local perms + perms=$(aws ec2 describe-security-groups --group-ids "$sg_id" \ + --query 'SecurityGroups[0].IpPermissions' --output json) + + echo "$perms" | jq -c '.[]' | while read -r perm; do + from_port=$(echo "$perm" | jq -r '.FromPort // "null"') + has_open_range=$(echo "$perm" | jq '[.IpRanges[]? | select(.CidrIp=="0.0.0.0/0")] | length') + + if [[ "$has_open_range" -gt 0 ]]; then + is_high_risk=false + if [[ "$from_port" == "null" ]]; then + is_high_risk=true + else + for p in "${HIGH_RISK_PORTS[@]}"; do + [[ "$from_port" == "$p" ]] && is_high_risk=true + done + fi + + if $is_high_risk; then + revoke_perm=$(echo "$perm" | jq '{IpProtocol, FromPort, ToPort, IpRanges: [.IpRanges[] | select(.CidrIp=="0.0.0.0/0")]}') + if $DRY_RUN; then + log "DRY RUN: would revoke ingress on $sg_id: $revoke_perm" + else + aws ec2 revoke-security-group-ingress --group-id "$sg_id" --ip-permissions "$revoke_perm" + log "Revoked 0.0.0.0/0 ingress for port $from_port on $sg_id" + fi + fi + fi + done +} + +list_active_findings() { + aws securityhub get-findings \ + --filters '{"RecordState":[{"Value":"ACTIVE","Comparison":"EQUALS"}],"WorkflowStatus":[{"Value":"NEW","Comparison":"EQUALS"}]}' \ + --query 'Findings[].{Id:Id,Generator:GeneratorId,Severity:Severity.Label,Title:Title}' \ + --output table +} + +# --scan looks up ACTIVE findings for the supported generator IDs and applies +# the matching fix automatically, using resource identifiers from the finding. +scan_and_remediate() { + local supported_ids='["aws-foundational-security-best-practices/v/1.0.0/S3.8","cis-aws-foundations-benchmark/v/1.4.0/2.1.1","aws-foundational-security-best-practices/v/1.0.0/EC2.19"]' + + aws securityhub get-findings \ + --filters '{"RecordState":[{"Value":"ACTIVE","Comparison":"EQUALS"}],"WorkflowStatus":[{"Value":"NEW","Comparison":"EQUALS"}]}' \ + --output json | + jq -c --argjson ids "$supported_ids" '.Findings[] | select(.GeneratorId as $g | $ids | index($g))' | + while read -r finding; do + generator=$(echo "$finding" | jq -r '.GeneratorId') + case "$generator" in + *S3.8) + bucket=$(echo "$finding" | jq -r '.Resources[0].Id' | awk -F':::' '{print $2}') + fix_s3_public_access "$bucket" + ;; + *2.1.1) + bucket=$(echo "$finding" | jq -r '.Resources[0].Id' | awk -F':::' '{print $2}') + fix_s3_encryption "$bucket" + ;; + *EC2.19) + sg_id=$(echo "$finding" | jq -r '.Resources[0].Id' | awk -F'/' '{print $NF}') + fix_open_security_group "$sg_id" + ;; + esac + done +} + +main() { + require_cmd aws + require_cmd jq + parse_args "$@" + + case "$FINDING_TYPE" in + list) list_active_findings ;; + scan) scan_and_remediate ;; + s3-public-access) + [[ -z "$BUCKET" ]] && { log "ERROR: --bucket required"; usage; } + fix_s3_public_access "$BUCKET" + ;; + s3-encryption) + [[ -z "$BUCKET" ]] && { log "ERROR: --bucket required"; usage; } + fix_s3_encryption "$BUCKET" + ;; + open-security-group) + [[ -z "$SG_ID" ]] && { log "ERROR: --sg-id required"; usage; } + fix_open_security_group "$SG_ID" + ;; + *) + log "ERROR: no valid --finding-type/--scan/--list provided" + usage + ;; + esac +} + +main "$@" \ No newline at end of file diff --git a/Backend/infrastructure/terraform/security-hub.tf b/Backend/infrastructure/terraform/security-hub.tf new file mode 100644 index 00000000..37a2416d --- /dev/null +++ b/Backend/infrastructure/terraform/security-hub.tf @@ -0,0 +1,455 @@ +############################################ +# AWS Security Hub - Compliance Aggregation +# +# Implements: +# - Security Hub enablement +# - CIS AWS Foundations Benchmark standard +# - Cross-region / cross-account finding aggregation +# - Automated remediation for common findings (EventBridge -> Lambda) +# - Weekly compliance report (EventBridge scheduled -> Lambda -> S3/SNS/SES) +############################################ + +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + archive = { + source = "hashicorp/archive" + version = ">= 2.4.0" + } + } +} + +############################################ +# Variables +############################################ + +variable "aggregator_region" { + description = "Region where findings from all linked regions will be aggregated." + type = string + default = "us-east-1" +} + +variable "linked_regions" { + description = "List of regions to link into the finding aggregator. Empty list = all regions." + type = list(string) + default = [] +} + +variable "enable_cis_standard" { + description = "Enable the CIS AWS Foundations Benchmark standard." + type = bool + default = true +} + +variable "cis_standard_version" { + description = "Version of the CIS AWS Foundations Benchmark to subscribe to." + type = string + default = "1.4.0" +} + +variable "enable_auto_remediation" { + description = "Whether to wire up EventBridge -> Lambda automated remediation for supported finding types." + type = bool + default = true +} + +variable "remediation_finding_types" { + description = "Security Hub finding generator IDs / rule identifiers that trigger auto-remediation." + type = list(string) + default = [ + "aws-foundational-security-best-practices/v/1.0.0/S3.8", # S3 Block Public Access + "aws-foundational-security-best-practices/v/1.0.0/EC2.19", # Unrestricted SG (0.0.0.0/0 on high-risk ports) + "aws-foundational-security-best-practices/v/1.0.0/IAM.6", # Hardware MFA for root + "cis-aws-foundations-benchmark/v/1.4.0/1.12", # Root account use + "cis-aws-foundations-benchmark/v/1.4.0/2.1.1", # S3 bucket encryption + ] +} + +variable "compliance_report_schedule" { + description = "EventBridge schedule expression for the weekly compliance report." + type = string + default = "cron(0 8 ? * MON *)" # Every Monday 08:00 UTC +} + +variable "notification_email" { + description = "Email address (SNS/SES) that receives the weekly compliance report and remediation alerts." + type = string +} + +variable "report_bucket_name" { + description = "S3 bucket name for storing weekly compliance reports. Must be globally unique." + type = string +} + +variable "tags" { + description = "Common tags applied to all resources." + type = map(string) + default = { + Project = "GistPin" + ManagedBy = "terraform" + Component = "security-hub" + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} +data "aws_partition" "current" {} + +############################################ +# 1. Security Hub Enablement +############################################ + +resource "aws_securityhub_account" "this" { + enable_default_standards = false # we control which standards are subscribed explicitly below + control_finding_generator = "SECURITY_CONTROL" + auto_enable_controls = true +} + +############################################ +# 2. CIS AWS Foundations Benchmark +############################################ + +resource "aws_securityhub_standards_subscription" "cis" { + count = var.enable_cis_standard ? 1 : 0 + standards_arn = "arn:${data.aws_partition.current.partition}:securityhub:${data.aws_region.current.name}::standards/cis-aws-foundations-benchmark/v/${var.cis_standard_version}" + + depends_on = [aws_securityhub_account.this] +} + +# AWS Foundational Security Best Practices - complements CIS, needed by several +# of the auto-remediation rules referenced above. +resource "aws_securityhub_standards_subscription" "fsbp" { + standards_arn = "arn:${data.aws_partition.current.partition}:securityhub:${data.aws_region.current.name}::standards/aws-foundational-security-best-practices/v/1.0.0" + + depends_on = [aws_securityhub_account.this] +} + +############################################ +# 3. Finding Aggregation (cross-region) +############################################ + +resource "aws_securityhub_finding_aggregator" "this" { + linking_mode = length(var.linked_regions) > 0 ? "SPECIFIED_REGIONS" : "ALL_REGIONS" + + # specified_regions is only used when linking_mode == SPECIFIED_REGIONS + specified_regions = length(var.linked_regions) > 0 ? var.linked_regions : null + + depends_on = [aws_securityhub_account.this] +} + +############################################ +# 4. Automated Remediation +# EventBridge rule matches Security Hub findings (NEW/NOTIFIED, non-passed) +# for the configured finding types and invokes a remediation Lambda, which +# shells out to the logic in remediate-findings.sh. +############################################ + +resource "aws_sns_topic" "security_alerts" { + name = "security-hub-remediation-alerts" + tags = var.tags +} + +resource "aws_sns_topic_subscription" "security_alerts_email" { + topic_arn = aws_sns_topic.security_alerts.arn + protocol = "email" + endpoint = var.notification_email +} + +data "archive_file" "remediation_lambda" { + type = "zip" + source_dir = "${path.module}/../lambda/remediation" + output_path = "${path.module}/build/remediation.zip" +} + +resource "aws_iam_role" "remediation_lambda" { + name = "security-hub-remediation-lambda" + tags = var.tags + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy" "remediation_lambda" { + name = "security-hub-remediation-permissions" + role = aws_iam_role.remediation_lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "Logs" + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:${data.aws_partition.current.partition}:logs:*:${data.aws_caller_identity.current.account_id}:*" + }, + { + Sid = "SecurityHubReadWrite" + Effect = "Allow" + Action = [ + "securityhub:GetFindings", + "securityhub:BatchUpdateFindings" + ] + Resource = "*" + }, + { + Sid = "RemediationActions" + Effect = "Allow" + Action = [ + "s3:PutBucketPublicAccessBlock", + "s3:PutEncryptionConfiguration", + "s3:GetBucketPolicy", + "ec2:DescribeSecurityGroups", + "ec2:RevokeSecurityGroupIngress", + "iam:GetAccountSummary", + "iam:UpdateAccountPasswordPolicy" + ] + Resource = "*" + }, + { + Sid = "Notify" + Effect = "Allow" + Action = "sns:Publish" + Resource = aws_sns_topic.security_alerts.arn + } + ] + }) +} + +resource "aws_lambda_function" "remediation" { + count = var.enable_auto_remediation ? 1 : 0 + function_name = "security-hub-auto-remediation" + role = aws_iam_role.remediation_lambda.arn + handler = "index.handler" + runtime = "python3.12" + timeout = 60 + filename = data.archive_file.remediation_lambda.output_path + source_code_hash = data.archive_file.remediation_lambda.output_base64sha256 + + environment { + variables = { + SNS_TOPIC_ARN = aws_sns_topic.security_alerts.arn + } + } + + tags = var.tags +} + +resource "aws_cloudwatch_event_rule" "remediation_trigger" { + count = var.enable_auto_remediation ? 1 : 0 + name = "security-hub-findings-remediation" + description = "Routes new/updated Security Hub findings of known types to the remediation Lambda." + + event_pattern = jsonencode({ + source = ["aws.securityhub"] + detail-type = ["Security Hub Findings - Imported"] + detail = { + findings = { + Compliance = { + Status = ["FAILED", "WARNING"] + } + RecordState = ["ACTIVE"] + Workflow = { + Status = ["NEW", "NOTIFIED"] + } + GeneratorId = var.remediation_finding_types + } + } + }) + + tags = var.tags +} + +resource "aws_cloudwatch_event_target" "remediation_lambda_target" { + count = var.enable_auto_remediation ? 1 : 0 + rule = aws_cloudwatch_event_rule.remediation_trigger[0].name + target_id = "security-hub-remediation-lambda" + arn = aws_lambda_function.remediation[0].arn +} + +resource "aws_lambda_permission" "allow_eventbridge_remediation" { + count = var.enable_auto_remediation ? 1 : 0 + statement_id = "AllowEventBridgeInvokeRemediation" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.remediation[0].function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.remediation_trigger[0].arn +} + +############################################ +# 5. Weekly Compliance Report +# Scheduled EventBridge rule -> Lambda that queries Security Hub, +# renders a report, stores it in S3, and emails/publishes a summary. +############################################ + +resource "aws_s3_bucket" "compliance_reports" { + bucket = var.report_bucket_name + tags = var.tags +} + +resource "aws_s3_bucket_public_access_block" "compliance_reports" { + bucket = aws_s3_bucket.compliance_reports.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "compliance_reports" { + bucket = aws_s3_bucket.compliance_reports.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "compliance_reports" { + bucket = aws_s3_bucket.compliance_reports.id + + rule { + id = "expire-old-reports" + status = "Enabled" + expiration { + days = 365 + } + } +} + +data "archive_file" "report_lambda" { + type = "zip" + source_dir = "${path.module}/../lambda/compliance-report" + output_path = "${path.module}/build/compliance-report.zip" +} + +resource "aws_iam_role" "report_lambda" { + name = "security-hub-compliance-report-lambda" + tags = var.tags + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy" "report_lambda" { + name = "security-hub-compliance-report-permissions" + role = aws_iam_role.report_lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "Logs" + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:${data.aws_partition.current.partition}:logs:*:${data.aws_caller_identity.current.account_id}:*" + }, + { + Sid = "SecurityHubRead" + Effect = "Allow" + Action = ["securityhub:GetFindings", "securityhub:GetInsights"] + Resource = "*" + }, + { + Sid = "ReportBucket" + Effect = "Allow" + Action = ["s3:PutObject"] + Resource = "${aws_s3_bucket.compliance_reports.arn}/*" + }, + { + Sid = "Notify" + Effect = "Allow" + Action = "sns:Publish" + Resource = aws_sns_topic.security_alerts.arn + } + ] + }) +} + +resource "aws_lambda_function" "compliance_report" { + function_name = "security-hub-weekly-compliance-report" + role = aws_iam_role.report_lambda.arn + handler = "index.handler" + runtime = "python3.12" + timeout = 120 + filename = data.archive_file.report_lambda.output_path + source_code_hash = data.archive_file.report_lambda.output_base64sha256 + + environment { + variables = { + REPORT_BUCKET = aws_s3_bucket.compliance_reports.bucket + SNS_TOPIC_ARN = aws_sns_topic.security_alerts.arn + } + } + + tags = var.tags +} + +resource "aws_cloudwatch_event_rule" "weekly_report_schedule" { + name = "security-hub-weekly-compliance-report" + description = "Triggers the weekly Security Hub compliance report generation." + schedule_expression = var.compliance_report_schedule + tags = var.tags +} + +resource "aws_cloudwatch_event_target" "weekly_report_target" { + rule = aws_cloudwatch_event_rule.weekly_report_schedule.name + target_id = "security-hub-weekly-report-lambda" + arn = aws_lambda_function.compliance_report.arn +} + +resource "aws_lambda_permission" "allow_eventbridge_report" { + statement_id = "AllowEventBridgeInvokeReport" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.compliance_report.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.weekly_report_schedule.arn +} + +############################################ +# Outputs +############################################ + +output "security_hub_account_id" { + value = aws_securityhub_account.this.id +} + +output "finding_aggregator_arn" { + value = aws_securityhub_finding_aggregator.this.arn +} + +output "remediation_lambda_arn" { + value = var.enable_auto_remediation ? aws_lambda_function.remediation[0].arn : null +} + +output "compliance_report_bucket" { + value = aws_s3_bucket.compliance_reports.bucket +} + +output "security_alerts_topic_arn" { + value = aws_sns_topic.security_alerts.arn +}