cloudguard-pro is an event-driven AWS security platform that detects and automatically remediates cloud misconfigurations. Seven Lambda functions, a shared data contract, dual IaC provisioning, and a unified findings store backed by DynamoDB Streams and SNS.

This writeup covers the architecture, the IaC split, and the implementation decisions that shaped the system.


Architecture

CloudTrail → EventBridge (default bus)
               ↓ forward rule
           EventBridge (cloudguard-pro-bus)
               ↓ rules per event type
    ┌──────────┬──────────┬──────────┐
    ▼          ▼          ▼          ▼
event-     sg-        s3-        iam-
ingestor  remediator remediator remediator
    │          │          │          │
    └──────────┴──────────┴──────────┘
               ↓ all write to
           DynamoDB (findings table)
               ↓ Streams
           finding-notifier → SNS → email

CloudWatch Schedule (6h)  → cis-scanner        → DynamoDB
CloudWatch Schedule (1d)  → security-hub-sync  → DynamoDB
AWS Config (continuous)   → managed rules      → Security Hub

Enter fullscreen mode Exit fullscreen mode

CloudTrail delivers management events to the default bus only. A forwarding rule relays relevant EC2, S3, and IAM events to a custom bus — cloudguard-pro-bus — which keeps cloudguard's routing isolated from anything else in the account. Remediators trigger independently and in parallel on specific event patterns. Everything writes to a single DynamoDB findings table.


Detection and Remediation Coverage

Auto-remediated:

Condition Lambda Action
SG ingress: 0.0.0.0/0 on port 22 or 3389 sg-remediator Revoke the offending rule
S3 public access block removed s3-remediator Re-enable all four block flags
IAM user: console access, no MFA iam-remediator Delete login profile

Flagged, human review:

Condition Lambda Severity
Root account API activity event-ingestor CRITICAL — no auto-remediation
S3 bucket policy or ACL change event-ingestor HIGH — intent ambiguous

Scheduled — CIS AWS Foundations Benchmark v1.4 (every 6 hours):

CIS Control Check
1.4 Root account has no active access keys
1.5 MFA enabled for root account
1.10 MFA for all IAM users with console access
3.1 CloudTrail enabled and logging
3.3 CloudTrail log file validation enabled
4.1 No unrestricted SSH (port 22)
4.2 No unrestricted RDP (port 3389)

Continuous — AWS Config managed rules:

CIS Control Rule
1.5 ROOT_ACCOUNT_MFA_ENABLED
1.10 IAM_USER_MFA_ENABLED
2.1.1 S3_BUCKET_LEVEL_PUBLIC_ACCESS_PROHIBITED
2.2.1 ENCRYPTED_VOLUMES
3.1 CLOUD_TRAIL_ENABLED
4.1 INCOMING_SSH_DISABLED

Security Hub aggregates Config findings alongside GuardDuty and Inspector. The security-hub-sync Lambda pulls active Security Hub findings into DynamoDB daily, unifying them with CloudTrail-sourced and CIS scanner findings into a single view.

Remediation scope is conservative by design. Only explicit on/off controls are auto-restored. Anything requiring interpretation of intent is flagged and left for human review.


Dual IaC: Terraform and Pulumi

Terraform owns the static infrastructure layer. Pulumi owns the compute layer.

Terraform: EventBridge bus and rules, DynamoDB table and GSIs, SNS topic, AWS Config recorder and managed rules, Security Hub, S3 state backend.

Pulumi: 7 Lambda functions, 7 IAM execution roles, Lambda Layer, EventBridge invoke permissions, DynamoDB Streams event source mapping.

The split is motivated by what each tool handles cleanly. Terraform's declarative model is well-suited to long-lived, low-churn infrastructure. Pulumi's imperative Python is better suited to compute provisioning that requires logic at deploy time — walking source directories, building zips, conditionally attaching layers:

# pulumi/__main__.py
def _make_lambda(name: str, role: aws.iam.Role, env_vars: dict, timeout: int = 30):
    zip_path = _zip_lambda(name)
    fn_name = f"cloudguard-{name.replace('_', '-')}"

    return aws.lambda_.Function(
        fn_name,
        name=fn_name,
        runtime="python3.12",
        code=pulumi.FileArchive(zip_path),
        handler="handler.handler",
        role=role.arn,
        layers=[shared_layer.arn],
        timeout=timeout,
        environment=aws.lambda_.FunctionEnvironmentArgs(variables={
            "FINDINGS_TABLE_NAME": findings_table_name,
            "SNS_TOPIC_ARN": sns_topic_arn,
            "AWS_ACCOUNT_ID": aws_account,
            **env_vars,
        }),
    )

Enter fullscreen mode Exit fullscreen mode

Deploy sequence is three-pass: Terraform creates the EventBridge rules with null targets → Pulumi creates Lambdas and exports ARNs → Terraform re-applies to wire Lambda ARNs as rule targets. Pulumi reads Terraform outputs via pulumi config set. The cross-tool dependency is explicit, documented, and handled without any custom glue.

The full rationale, tradeoffs, and honest assessment of when you'd choose one over the other is in docs/iac-comparison.md in the repo.


The Finding Data Contract

Every Lambda in the system — ingestor, remediators, CIS scanner, Security Hub sync — writes the same Finding dataclass:

@dataclass
class Finding:
    service: str
    finding_type: str
    severity: Severity          # CRITICAL, HIGH, MEDIUM, LOW, INFO
    source: FindingSource       # CLOUDTRAIL, CONFIG_RULE, CIS_SCANNER, SECURITY_HUB
    resource_id: str
    description: str
    region: str
    account_id: str

    finding_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    created_at: str = field(default_factory=_utc_now_iso)
    status: FindingStatus = FindingStatus.DETECTED
    remediation_action: str | None = None
    remediated_at: str | None = None
    raw_event_summary: dict | None = None
    cis_check_id: str | None = None

Enter fullscreen mode Exit fullscreen mode

finding-notifier reads this same model from DynamoDB Streams before publishing SNS alerts. No raw dict juggling across function boundaries. Schema drift surfaces as a dataclass validation error in tests, not a silent write to DynamoDB with a missing field.


DynamoDB Schema

Table: cloudguard-findings
Billing: PAY_PER_REQUEST
Streams: Enabled, NEW_AND_OLD_IMAGES — triggers finding-notifier on INSERT

Key Type Purpose
finding_id (PK) String UUID Unique per finding
created_at (SK) ISO8601 Time ordering within partition
GSI: severity-index PK: severity Query all CRITICAL findings
GSI: service-index PK: service Query all EC2 or IAM findings

Testing

All 70 tests run against moto-mocked AWS services. No live account required:

pip install -r requirements-dev.txt
python -m pytest tests/ -v
# 70 passed — 85.39% coverage

Enter fullscreen mode Exit fullscreen mode

Moto mocks DynamoDB, EC2, S3, IAM, SNS, CloudTrail, Config, and Security Hub. Each handler is tested in isolation with production-representative CloudTrail event payloads. CI runs all six jobs on every push: pytest, Bandit, Checkov, Ruff, and two guarded deploy jobs that only run on manual dispatch.


Sprint Results — July 27, 2026

  • Open SSH rule (port 22, 0.0.0.0/0) added to a security group — auto-revoked within 60 seconds. IpPermissions: [] confirmed via CLI. CRITICAL SNS alert received.
  • IAM user created with console access and no MFA — login profile deleted within 30 seconds. NoSuchEntity confirmed via CLI. CRITICAL SNS alert received.
  • CIS scanner invoked manually — CIS_4_1_UNRESTRICTED_PORT_22 (HIGH/DETECTED) and CIS_3_1_PASS (INFO/ACKNOWLEDGED) written to DynamoDB.
  • 7 distinct SNS alerts received across all severity levels.
  • Pulumi: 37 resources created in 30 seconds. pulumi destroy cleaned up cleanly.
  • Total sprint cost: ~$3–4.

Repo

github.com/cypher682/cloudguard-pro