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.
NoSuchEntityconfirmed via CLI. CRITICAL SNS alert received. - CIS scanner invoked manually —
CIS_4_1_UNRESTRICTED_PORT_22(HIGH/DETECTED) andCIS_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 destroycleaned up cleanly. - Total sprint cost: ~$3–4.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.