Back to blog
11 min readTechVersa Engineering

Mapping CUI Data Flows in AWS Multi-VPC Environments

AWSComplianceSecurity

vpc-public → vpc-app → vpc-cui, no other route exists

vpc-public

ALB, WAF, internet-facing

vpc-app

EC2 / ECS compute

vpc-cui

No IGW route, ever

Controlled Unclassified Information (CUI) doesn't get a special AWS service. There's no "CUI mode" you flip on. What you get is a set of controls in NIST SP 800-171, and the job is translating those controls into an account structure, a network topology, and a key management setup that an auditor can actually walk through. This is that translation, done once, so you're not re-deriving it per engagement.

The starting mistake: one VPC, tagged resources

The most common failure mode isn't a missing control: it's architecture that makes the controls unverifiable. A single VPC with CUI-handling instances distinguished only by resource tags technically satisfies "we know which resources touch CUI," but it fails the boundary-protection requirement (3.1.3) the moment someone asks "show me the network path that proves this instance can't reach the public internet." If the answer requires reading fifteen security group rules and hoping none of them conflict, that's not a boundary. That's a policy document describing a boundary that doesn't structurally exist.

The fix is a dedicated VPC for anything that touches CUI, full stop. Not a subnet. Not a security group. A VPC, so the boundary shows up in the account's actual network topology, not just in your documentation.

A minimal multi-VPC layout

Three VPCs, each with one job:

  • `vpc-public`: internet-facing services (ALB, API Gateway, WAF). No CUI ever lands here.
  • `vpc-app`: application compute (EC2/ECS/EBS). Talks to vpc-public inbound and vpc-cui outbound, nothing else.
  • `vpc-cui`: CUI-adjacent storage: RDS, KMS-encrypted EBS, any service that reads or writes controlled data. No route to the internet gateway, no route to vpc-public, ever.

Connectivity between them goes through a Transit Gateway with explicit route tables per attachment, not a full mesh of VPC peering, which tends to accumulate routes nobody remembers approving. Each attachment gets its own route table, and vpc-cui's route table has exactly one route beyond local: the /32 or /24 that covers the specific app-tier subnets allowed to reach it.

hcl
resource "aws_ec2_transit_gateway_route_table" "cui" {
  transit_gateway_id = aws_ec2_transit_gateway.main.id
  tags = { Name = "tgw-rt-cui-inbound-only" }
}

resource "aws_ec2_transit_gateway_route" "app_to_cui" {
  destination_cidr_block        = "10.20.0.0/24" # vpc-cui CIDR
  transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.app.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.cui.id
}

# No route to vpc-public. No route to the internet. If it isn't
# listed here, vpc-cui cannot reach it, and that's enforced by
# the absence of a route, not by a security group someone could edit.

Security groups are the second layer, not the first. vpc-cui's RDS security group allows inbound 5432 from exactly the app tier's security group ID, never a CIDR range, never 0.0.0.0/0 even scoped to VPC. Security-group-to-security-group references mean the rule stays correct even when instances get replaced.

Encryption: envelope encryption, not "encryption enabled"

"Encrypted at rest" is a checkbox in the RDS console, but the control you actually need to demonstrate (3.13.11) is that you control the key lifecycle. That's what a customer-managed KMS key gets you over the AWS-managed default: you can rotate it, restrict who can use it via key policy, and revoke access without deleting the underlying data.

Envelope encryption is the pattern under the hood, and it's worth understanding directly rather than trusting the RDS abstraction, because you'll need to implement it yourself for anything outside a managed service (application-level encryption of a specific field, for instance).

python
import boto3

kms = boto3.client("kms", region_name="us-gov-west-1")

# 1. Ask KMS for a data key. It returns both the plaintext key
#    (used once, in memory, then discarded) and the same key
#    encrypted under your CMK (stored alongside the ciphertext).
response = kms.generate_data_key(
    KeyId="arn:aws:kms:us-gov-west-1:111122223333:key/cui-cmk",
    KeySpec="AES_256",
)
plaintext_key = response["Plaintext"]
encrypted_key = response["CiphertextBlob"]

# 2. Encrypt the actual payload locally with the plaintext key
#    (AES-GCM, application-level, not shown here) and store
#    ciphertext + encrypted_key together. Discard plaintext_key.

# 3. To decrypt later: ask KMS to unwrap encrypted_key, which
#    requires kms:Decrypt permission on the CMK: this is your
#    real access-control point, not the S3/RDS ACL.
decrypted = kms.decrypt(CiphertextBlob=encrypted_key)["Plaintext"]

The reason this matters for CUI specifically: your key policy is a control you can point an auditor at directly. "Only the cui-app-role and cui-backup-role IAM roles can call kms:Decrypt on this key" is a falsifiable, auditable statement. "The database has encryption enabled" is not. It doesn't say who can read the plaintext.

GuardDuty as the boundary's alarm system

A boundary that isn't monitored is a boundary you're trusting rather than verifying. GuardDuty in vpc-cui's account, with VPC Flow Logs and DNS logs as data sources, catches the failure modes that matter here specifically:

bash
aws guardduty create-detector \
  --enable \
  --finding-publishing-frequency FIFTEEN_MINUTES \
  --data-sources '{"S3Logs":{"Enable":true},"Kubernetes":{"AuditLogs":{"Enable":false}}}'

The findings worth routing to a pager rather than a weekly digest, for a CUI boundary specifically:

  • UnauthorizedAccess:EC2/TorIPCaller: something in vpc-cui reached the internet at all, which given the route table above should be structurally impossible, so this finding means the route table changed underneath you.
  • Recon:IAMUser/MaliciousIPCaller scoped to the cui-app-role: credential reuse from outside the expected CIDR.
  • Trojan:EC2/DriveBySourceTraffic: outbound connection pattern from an instance that has no business making outbound connections.

Wire these through EventBridge to a Lambda that checks severity and pages only on High/Critical. GuardDuty's Low-severity findings are frequent enough that paging on all of them trains people to ignore the pager.

Mapping to NIST SP 800-171 / CMMC Level 2

This is the table an assessor actually wants, control ID to concrete implementation:

ControlRequirementImplementation
3.1.3Control CUI flow, boundary enforcementDedicated vpc-cui, no IGW route, TGW route table allow-list
3.1.13Cryptographic mechanisms for remote accessSession Manager instead of bastion + SSH; no inbound 22 anywhere
3.13.8Cryptographic mechanisms during transmissionTLS 1.2+ enforced at ALB, VPC endpoints (not internet) for AWS API calls from vpc-cui
3.13.11FIPS-validated cryptographyKMS with customer-managed keys, AWS's FIPS 140-2 validated HSM boundary
3.14.6Monitor for unauthorized connectionsGuardDuty + VPC Flow Logs, routed to a SIEM with 12-month retention
3.3.1Audit log creation and retentionCloudTrail with log file validation, S3 Object Lock (see the CloudTrail/QLDB piece for the full pipeline)

None of this is exotic. It's mechanical: the work is doing it before the architecture exists informally and has to be retrofitted, which is always more expensive and always leaves gaps the informal version never surfaces until an assessment does.

Building something like this?

Tell us what you're working on, we'll scope it together.

Start a Project