Skip to content
Luigi Carpio
← All writing

Where CJI Is Allowed to Go: A Deny-by-Default VPC

aws-lab vpc sc-7 compliance-as-code nist-800-53 cjis-v6.1

Last verified: 2026-10

Lab 1 answered who can act. Lab 2 answered where the evidence lives. Lab 3 is the boundary around CJI traffic.

SC-7 is the control a CJIS assessment asks first. Is the enclave isolated. Does the private tier have a route to the internet. Can S3 and KMS be reached without leaving the VPC.

A VPC is the answer. The default VPC is the wrong one.

I built it in the same brownfield study account as the first two labs, in us-east-1. Console first. Then the CLI an assessor would run.

Flow Logs write into the Lab 2 bucket. Lab 2 named them as the first downstream writer. This is that write.

Brownfield pre-flight

I read the account before I created anything. Lab 1’s RequireMFA policy denied the plain access keys on every call except get-caller-identity. I used an STS session from sts get-session-token. Same IA-2 gate. Still on.

---------------------------------------------------------------
|                        DescribeVpcs                         |
+----------------+----------+-------------------------+-------+
|      cidr      | default  |           id            | name  |
+----------------+----------+-------------------------+-------+
|  172.31.0.0/16 |  True    |  vpc-0d2dc728b18beb8f1  |  None |
+----------------+----------+-------------------------+-------+

Six public subnets, 172.31.0.0/20 through 172.31.80.0/20, all with public IP assignment on. One internet gateway, igw-0391230009c821485, attached to that default VPC. The default security group allows all inbound from itself:

{
  "vpc": "vpc-0d2dc728b18beb8f1",
  "id": "sg-024861cd294d005e3",
  "inbound": [
    {
      "IpProtocol": "-1",
      "UserIdGroupPairs": [
        {
          "UserId": "<ACCOUNT_ID>",
          "GroupId": "sg-024861cd294d005e3"
        }
      ]
    }
  ]
}
FindingControlAfter
Default VPC vpc-0d2dc728b18beb8f1, six public subnets, default SG allowing all inbound from itselfSC-7Left it. Built grc-lab3-enclave beside it.
Zero flow logsAU-12fl-06ac3e7672ead2628, traffic All, to the Lab 2 bucket
Zero VPC endpointsSC-7S3 Gateway, plus KMS and Secrets Manager Interface
Zero NAT gateways, zero Elastic IPs. The Cantrill-era VPCs were already goneCM-8Nothing to delete

Two Lab 2 policies had to change before Flow Logs could write.

The CMK alias/s3-compliant had a console key policy and no service principal. Delivery needs delivery.logs.amazonaws.com on that key. I added AllowVPCFlowLogsDelivery: Encrypt, Decrypt, ReEncrypt*, GenerateDataKey*, DescribeKey, conditioned on aws:SourceAccount.

The bucket policy on grc-lab2-evidence-08132026 was DenyInsecureTransport only. If the flow-log creator owns the bucket, the console attaches its own policy and overwrites what is there. That would have stripped the TLS deny. I attached the merged policy myself first. Three Sids: DenyInsecureTransport, AWSLogDeliveryWrite, AWSLogDeliveryAclCheck. Ownership was BucketOwnerEnforced. The delivery statement still asks for the bucket-owner-full-control ACL, and that canned ACL is accepted with ACLs disabled.

Object Lock was the open question. S3 documents server access logging as incompatible with an Object Lock bucket. It does not name Flow Logs. I did not turn Object Lock off to find out.

The build, console-first

VPC grc-lab3-enclave, vpc-0ea1dd507aed3a838, 10.50.0.0/16. DNS resolution and DNS hostnames both on. Interface endpoint private DNS needs both.

Four subnets, two AZs. Auto-assign public IP on the two public subnets only.

NameCIDRAZSubnet
public-a10.50.1.0/24us-east-1asubnet-088388666c9704030
public-b10.50.2.0/24us-east-1bsubnet-018c167510798c765
private-a10.50.11.0/24us-east-1asubnet-0459369b1ca90e508
private-b10.50.12.0/24us-east-1bsubnet-02551122285a6ed3a

Internet gateway igw-0749099ae4de78d75, attached to the VPC. One NAT, nat-0665918a82db524bb, in public-a, with Elastic IP eipalloc-0c30e2937c4b481d7. The console offered a Regional NAT. I took Zonal: one NAT, one address. One NAT per AZ is the HA pattern. I annotated it. I did not build it.

rt-public (rtb-081ddcc4d8235da11) sends 0.0.0.0/0 to the internet gateway and is associated with both public subnets. rt-private (rtb-07d00c749dbd39988) sends 0.0.0.0/0 to the NAT and is associated with both private subnets. I left the main route table unassociated.

This frame is from after the S3 Gateway endpoint, so the prefix-list route is in the table with the NAT route.

VPC route table rt-private with three active routes: prefix list pl-63a5400a to a gateway endpoint, 0.0.0.0/0 to a NAT gateway, and 10.50.0.0/16 local. No internet gateway target. The owner ID is blacked out.

Three routes. 10.50.0.0/16 is local. 0.0.0.0/0 goes to the NAT. Prefix list pl-63a5400a goes to Gateway endpoint vpce-0c4e762402a760db6. There is no igw- target. That is SC-7(3).

Security groups. Each inbound source is the previous group’s ID, not a CIDR. Membership in the chain is the only way in. That is SC-7(5). I deleted the default allow-all egress on every group. The console adds it silently. The VPC’s own default security group stayed untouched.

Group names cannot start with sg-. AWS reserves that prefix for IDs. I created them in two passes, because the edge group’s egress points at the app group, which did not exist yet.

SGIDInboundOutbound
public-alb-443sg-07bda0a798bbe15a3TCP 443 from 0.0.0.0/0TCP 8080 to app-from-alb
app-from-albsg-0181243cfefd0ce32TCP 8080 from public-alb-443TCP 5432 to data-from-app, TCP 443 to vpce-443, TCP 443 to pl-63a5400a
data-from-appsg-0b2a87f3f86949fceTCP 5432 from app-from-albnone
vpce-443sg-01962e005c7f28467TCP 443 from 10.50.0.0/16none

EC2 security group data-from-app with one inbound rule, TCP 5432 sourced from app-from-alb, description "data from app only", and zero outbound rules. The owner ID is blacked out.

On 2026-09-27 I found the verify step would fail as designed. data-from-app has no egress, and public-alb-443 only egresses 8080, so Session Manager could not reach either instance. The probe’s nc to port 22 would also die at its own security group before the NACL saw it. For the verify window I attached a temporary helper group on each instance: egress 443 to 0.0.0.0/0, and on the probe also egress 22 to 10.50.0.0/16. Names were helper-target-ssm and helper-probe-ssm. Both were deleted at teardown. The chain in the table is what remains.

The private NACL is nacl-private, acl-0f812ab066731803b. NACLs are stateless. The lowest matching rule number wins, and a custom NACL ends in an implicit deny.

The first subnet-association save did not stick. The page gave no warning. This frame is that page. Details says Associated with nothing.

VPC network ACL nacl-private inbound rules: rule 50 denies SSH from 0.0.0.0/0, rule 100 allows all traffic from 10.50.0.0/16, rule 110 allows TCP 1024-65535 from 0.0.0.0/0, and the implicit deny. The Details panel shows Associated with nothing. The owner ID is blacked out.

I saved again. After that, describe-network-acls lists both private subnets on acl-0f812ab066731803b:

"subnets": [
    "subnet-0459369b1ca90e508",
    "subnet-02551122285a6ed3a"
]

Those are private-a and private-b.

Rule 50 denies SSH from 0.0.0.0/0. Rule 100 allows everything from 10.50.0.0/16. Rule 50 is the lower number, so it wins even when the source is inside the VPC. The probe at 10.50.1.133 matched rule 50 before rule 100 was considered.

That deny would also block an administrator. It is deliberate. Nobody SSHes into the private tier. Session Manager is the path.

Rule 110 inbound allows TCP 1024-65535 from 0.0.0.0/0, because a NACL is stateless and NAT return traffic needs its own rule. Outbound: 100 allows the VPC CIDR, 110 allows HTTPS to 0.0.0.0/0, 120 allows the ephemeral reply range. NACL entries have no description field. The intent has to live in comments when this is code.

Endpoints, all available:

VPC endpoints list showing an S3 Gateway endpoint and Interface endpoints for KMS and Secrets Manager, each available.

EndpointIDType
com.amazonaws.us-east-1.s3vpce-0c4e762402a760db6Gateway, on both route tables. Private DNS false.
com.amazonaws.us-east-1.kmsvpce-0f9fc9782bc1e3cd8Interface, both private subnets, private DNS on, security group vpce-443
com.amazonaws.us-east-1.secretsmanagervpce-063ef69e2ec509dbeInterface, same subnets, private DNS on, same security group

Flow log fl-06ac3e7672ead2628, name grc-lab3-flowlog. Filter All. Destination S3, the Lab 2 bucket. Max aggregation interval 10 minutes. Created 2026-09-30 at 15:25 PDT. The bucket policy already granted the delivery principal, so the console left it alone. I diffed the policy before and after. Byte-identical.

VPC flow log grc-lab3-flowlog with destination type s3, traffic type All, destination bucket grc-lab2-evidence-08132026, and state Active.

The first objects listed at 15:31 PDT. Six minutes, on a 5-minute delivery cadence. Object Lock did not block delivery. Those objects are SSE-KMS with the Lab 2 CMK, Governance mode, retain until 2027-09-30.

I cut the WAF half (web ACL, ALB, Shield Standard, Resolver query logging) because those are edge controls, not boundary controls, and an ALB costs money while it sits idle.

Verify like an assessor

I re-ran the reads from an MFA session in us-east-1 on 2026-09-30, after the flow log had been up long enough to deliver.

describe-route-tables on rt-private returned three routes: 10.50.0.0/16 local, 0.0.0.0/0 via nat-0665918a82db524bb, and pl-63a5400a via vpce-0c4e762402a760db6. No internet gateway.

describe-security-groups on data-from-app returned one permission: TCP 5432 from sg-0181243cfefd0ce32. The CIDR list was empty.

describe-vpc-endpoints returned the S3 Gateway with private DNS false, and the KMS and Secrets Manager Interface endpoints with private DNS true. All three available.

describe-flow-logs returned status ACTIVE, destination type s3, destination arn:aws:s3:::grc-lab2-evidence-08132026, traffic ALL, DeliverLogsStatus SUCCESS, error empty.

head-object on the first delivered file:

{
  "sse": "aws:kms",
  "kms": "<CMK_ARN>",
  "lock": "GOVERNANCE",
  "until": "2027-09-30T22:31:11.245000+00:00"
}

From Session Manager on the private instance, aws kms describe-key --key-id alias/s3-compliant succeeded through the Interface endpoint.

The negative test used two t3.micro instances, no key pairs. Probe i-031b29c0a0cca114f at 10.50.1.133 in public-a. Target i-02ea9ba33fc128901 at 10.50.11.61 in private-a. Session Manager reached the target through the NAT, using the temporary helper groups.

Session Manager on the public probe: ncat to 10.50.11.61 port 22 times out between 2026-09-30T22:48:15Z and 2026-09-30T22:48:20Z.

nc -zv -w 5 10.50.11.61 22 started at 22:48:15Z and returned Ncat: TIMEOUT at 22:48:20Z.

The next delivered file recorded both ENIs. Fields are version, account, interface, source, destination, source port, destination port, protocol, packets, bytes, start, end, action, log-status.

2 <ACCOUNT_ID> eni-00892a2c4ff93f22f 10.50.1.133 10.50.11.61 44078 22 6 5 300 1790808494 1790808500 REJECT OK
2 <ACCOUNT_ID> eni-0c9e3ed55331d2fea 10.50.1.133 10.50.11.61 44078 22 6 5 300 1790808500 1790808527 ACCEPT OK

The target ENI rejected 5 packets. The probe ENI accepted them, because helper-probe-ssm allowed the egress. The drop is on the target side.

The REJECT record proves the boundary held, and that the drop was written to SSE-KMS storage under Object Lock. It does not say whether NACL rule 50 or the data-from-app security group dropped the packet. Both would have. Flow logs do not name the control that decided.

The object that holds that line. Account ID blacked out in the key, the S3 URI, and the ARN.

S3 object page for a delivered VPC flow log in bucket grc-lab2-evidence-08132026, object key ending 20260930T2245Z, last modified 2026-09-30 15:51 PDT. The account ID is blacked out in the object key, S3 URI, and ARN.

head-object on that file: SSE-KMS, Governance, retain until 2027-09-30T22:51:11Z.

Teardown the same night, from the CLI. Instances terminated at 2026-09-30T22:59:29Z. Both helper groups deleted. NAT deleted at 22:59:30Z. The Elastic IP released. Both Interface endpoints deleted. The S3 Gateway endpoint stayed.

The NAT had been up since 2026-09-26 17:20 PDT. I stopped after the route tables and meant to delete it. It was still available the next evening, and I left it up so the rest of the build could finish in one sitting. It billed through 2026-09-30 16:00 PDT. About 95 hours, about $4.30. rt-private’s default route is a blackhole now. That is what deleting the NAT does. The VPC, subnets, security groups, NACL, Gateway endpoint, and the flow log stayed. They cost nothing with no instances behind them. I kept the SSM role grc-lab3-ssm-instance for the module verify and deleted it on 2026-10-07.

Control mappings

ConfigurationNIST 800-53 Rev 5FedRAMP HighCJIS v6.1
Subnet split with no IGW route on privateSC-7, SC-7(3)SC-7, SC-7(3)SC-7, SC-7(3)
Least-privilege SG chainSC-7(5), AC-4SC-7(5), AC-4SC-7(5), AC-4
Deny-by-default NACL on private subnetsAC-4, SC-7AC-4, SC-7AC-4, SC-7
VPC Flow Logs (ALL) to the Lab 2 WORM bucketAU-2, AU-3, AU-12, AU-9AU-2, AU-3, AU-12, AU-9AU-2, AU-3, AU-12, AU-9
VPC Endpoints for S3 / KMS / Secrets ManagerSC-7, AC-4, SC-8SC-7, AC-4, SC-8SC-7, AC-4, SC-8
Shed: authenticated proxySC-7(8)SC-7(8)SC-7(8)
Shed: WAF managed rule groupsSI-3, SI-4, SC-7SI-3, SI-4, SC-7SI-3, SI-4, SC-7
Shed: Shield StandardSC-5SC-5SC-5
Shed: Route 53 Resolver query loggingAU-2, AU-3AU-2, AU-3AU-2, AU-3

The shed rows were not built. SC-7(8) is one of them. A Gateway endpoint is a route. An Interface endpoint is an ENI. The private default route goes to a NAT, and that NAT does not authenticate. None of those is an authenticated proxy. SC-8 on the endpoint row is TLS on the client calls, plus DenyInsecureTransport on the Lab 2 bucket. CJIS v6.1 includes the same 800-53 IDs on each built row, so the CJIS column matches them. The AU rows are the Flow Logs write Lab 2 left open.

The fail-closed module

I then coded the same boundary as vpc-boundary in aws-grc-terraform-modules (v1.3.0).

The module’s inputs give a consumer no way to build a private subnet with an internet route, or a VPC without flow logs. The private default route names the NAT and has no gateway_id argument, so no input can point it at the internet gateway. That guardrail is structural, not a validation block. flow_logs_bucket_arn is required, nullable = false, and shape-checked. There is no flow-logs-off mode. The same AZ twice, a missing bucket ARN, and a string that is not an ARN all fail at plan time.

I applied it to this account on 2026-10-07 against the Lab 2 bucket, then destroyed it. data-from-app and vpce-443 came up with zero egress rules. AWS adds an allow-all egress rule to every new group, and the provider removes it. rt-private had no internet gateway route. The flow log reported SUCCESS, and the first file landed SSE-KMS under Object Lock. The bucket policy was identical before and after. A second plan showed no changes.

One limit is written into the module README. The delivery grant is hand-added to the Lab 2 bucket policy, and s3-compliant-bucket owns that policy in Terraform. The next apply of that stack reverts the grant.

GovCloud and CJIS

I ran this in commercial us-east-1. A GovCloud assessment changes the Region in the hostname. The KMS Interface service name becomes com.amazonaws.us-gov-west-1.kms. The hostname becomes kms.us-gov-west-1.amazonaws.com. The suffix stays amazonaws.com.

kms-fips.us-east-1.amazonaws.com is a separate Interface endpoint in commercial AWS. GovCloud also publishes a separate FIPS endpoint, kms-fips.us-gov-west-1.amazonaws.com, service name com.amazonaws.us-gov-west-1.kms-fips. CJIS in-transit FIPS points there. I noted that. I did not create a second endpoint.

This VPC is the enclave. Anything that terminates CJI outside it needs its own SC-7 story.

What lands here next

Lab 4’s CloudTrail writes to the same bucket. Separating this study account from that lab is a decision I have not made, and it comes first.

The module is vpc-boundary v1.3.0.