Photo by Jandira Sonnendeck on Unsplash
To find VPCs without flow logs, list every VPC with DescribeVpcs, list every flow log with DescribeFlowLogs, and match each flow log’s ResourceId to a VPC, one of its subnets or one of its network interfaces. A VPC with no active match has no flow logs. The script below does this per Region and can create S3 flow logs for the gaps behind --apply.
Flow logs are the record you want after something odd happens on the network: which address talked to which port, and whether a security group or network ACL accepted or rejected it. You can’t turn them on after the fact for traffic that already happened, so the gap only shows up when you need the data. This example is for security reviewers and platform engineers who need to find VPCs without flow logs across Regions and close the gaps at a predictable cost.
You get a TypeScript script for the AWS SDK for JavaScript v3 that is read-only by default. It sits with the other AWS SDK v3 security and cost examples and pairs naturally with the checks that confirm CloudTrail is enabled and logging in every Region: CloudTrail records API calls, flow logs record packets. For HTTP requests to your APIs, the script to find API Gateway stages without access logging closes the same gap.
What counts as a VPC without flow logs?
A flow log can watch a whole VPC, a single subnet or a single network interface (ENI). A VPC-level flow log covers every ENI in the VPC, including ones created later. Subnet- and ENI-level logs only cover what they’re attached to, which is why the script reports four states instead of yes or no:
| Coverage | Meaning | What to do |
|---|---|---|
vpc |
An active VPC-level flow log that delivers successfully | Nothing; check its traffic type |
failing |
A VPC-level flow log exists but DeliverLogsStatus is FAILED |
Fix the bucket policy or IAM role; the logs aren’t arriving |
partial |
No VPC-level log, but some subnets or ENIs have one | Decide whether the uncovered subnets matter |
none |
Nothing watches the VPC or anything in it | Create a flow log, or delete the VPC if it’s empty |
Traffic type matters too. REJECT only shows blocked attempts, which is cheaper but won’t tell you what an attacker did after getting in. ALL records both accepted and rejected traffic. Flow logs also skip some traffic by design, such as queries to the Amazon DNS server, instance metadata at 169.254.169.254, DHCP and ARP.
CloudWatch Logs or S3: what does each destination cost?
Flow logs are billed as vended logs. As of September 2026, the Amazon CloudWatch pricing page lists these us-east-1 rates for the first 10 TB a month (lower tiers apply above that):
| Destination | Charge per GB | Then you also pay |
|---|---|---|
| CloudWatch Logs, Standard class | $0.50 ingestion | $0.03 per GB-month stored |
| S3, plain text | $0.25 delivery | S3 storage ($0.023 per GB-month in S3 Standard) |
| S3, Parquet | $0.25 delivery + $0.03 conversion | S3 storage |
Worked example for 200 GB of flow log data a month: CloudWatch Logs Standard costs 200 × $0.50 = $100.00 before storage. S3 in plain text costs 200 × $0.25 = $50.00, and Parquet adds 200 × $0.03 = $6.00 for $56.00. The AWS VPC documentation says Parquet queries run 10 to 100 times faster than plain text and take about 20 percent less space with Gzip, which is why the script’s --apply path writes Parquet to S3.
Choose CloudWatch Logs when you need metric filters, alarms or Logs Insights on recent traffic, and set a retention period so storage doesn’t grow forever; the script to set CloudWatch log retention for all log groups handles that. Choose S3 for long-term, low-cost retention and query it with Athena using AWS SDK v3, then add S3 lifecycle rules to the log bucket so old files expire or move to a colder class.
What does the script do?
- Lists flow logs
paginateDescribeFlowLogsreturns every flow log in the Region; onlyACTIVEones count, grouped byResourceId. - Maps subnets and ENIs to VPCs
paginateDescribeSubnetsandpaginateDescribeNetworkInterfaceslet a subnet- or ENI-level log count as partial coverage, and give an ENI count per VPC. - Classifies every VPC
paginateDescribeVpcs, thenvpc,failing,partialornone, with the traffic type and destination of existing logs. - Previews the fixWith
--bucket-arn, it prints the flow log it would create for each uncovered VPC.{region}in the ARN is replaced per Region. - Creates flow logs only with
--applyOneCreateFlowLogscall per Region, VPC-level, to S3 in Parquet, taggedcreated-by, reporting anyUnsuccessfulitems.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus the@aws-sdk/client-ec2package. - An AWS profile the SDK can resolve; the guide to AWS SDK v3 credential providers such as fromIni and fromSSO covers the options.
- For
--apply: an S3 bucket that already has the log delivery bucket policy from the VPC documentation (principaldelivery.logs.amazonaws.com, withaws:SourceAccountandaws:SourceArnconditions).
Bucket policy overwrite: if the caller owns the bucket and has s3:GetBucketPolicy and s3:PutBucketPolicy, AWS attaches its own log delivery policy and it overwrites any existing bucket policy. Add the statements yourself and leave those two permissions out of the role that runs this script.
Which IAM permissions does it need?
The report needs four describe actions, which don’t support resource-level permissions. The second statement is only for --apply: ec2:CreateFlowLogs plus the logs:CreateLogDelivery and logs:DeleteLogDelivery actions the VPC documentation requires for S3 destinations.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReportFlowLogCoverage",
"Effect": "Allow",
"Action": [
"ec2:DescribeVpcs",
"ec2:DescribeFlowLogs",
"ec2:DescribeSubnets",
"ec2:DescribeNetworkInterfaces"
],
"Resource": "*"
},
{
"Sid": "CreateS3FlowLogsOnlyWithApply",
"Effect": "Allow",
"Action": [
"ec2:CreateFlowLogs",
"ec2:CreateTags",
"logs:CreateLogDelivery",
"logs:DeleteLogDelivery"
],
"Resource": "*"
}
]
}
Run the report with only the first statement. To tighten the second, the IAM policy generator for TypeScript AWS SDK code drafts a policy from the script, and the checklist to review a generated IAM policy for least privilege covers what to check before you attach it.
The full script to find VPCs without flow logs
// find-vpcs-without-flow-logs.ts
// Lists every VPC in the chosen Regions and reports whether it has an active VPC-level flow log,
// only subnet- or ENI-level flow logs ("partial"), a flow log whose delivery is failing, or nothing.
// Read-only by default. With --apply --bucket-arn it creates a VPC-level flow log to S3 for each VPC with none.
// Usage:
// npx tsx find-vpcs-without-flow-logs.ts [--regions us-east-1,eu-west-1] [--csv vpc-flow-logs.csv]
// npx tsx find-vpcs-without-flow-logs.ts --regions us-east-1 --bucket-arn arn:aws:s3:::acme-flow-logs-{region}/vpc/ [--traffic ALL] [--apply]
import { randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs";
import {
CreateFlowLogsCommand,
EC2Client,
paginateDescribeFlowLogs,
paginateDescribeNetworkInterfaces,
paginateDescribeSubnets,
paginateDescribeVpcs,
type FlowLog,
type TrafficType,
} from "@aws-sdk/client-ec2";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const csvPath = flag("--csv");
const bucketArn = flag("--bucket-arn"); // may contain {region}, e.g. arn:aws:s3:::acme-flow-logs-{region}/vpc/
const trafficArg = (flag("--traffic") ?? "ALL").toUpperCase();
const apply = args.includes("--apply");
const TRAFFIC_TYPES: TrafficType[] = ["ACCEPT", "REJECT", "ALL"];
const traffic = TRAFFIC_TYPES.find((t) => t === trafficArg);
if (!traffic) throw new Error(`--traffic must be one of ${TRAFFIC_TYPES.join(", ")}`);
if (apply && !bucketArn) throw new Error("--apply needs --bucket-arn arn:aws:s3:::bucket[/prefix/]");
interface Row {
Region: string;
Vpc: string;
Name: string;
Default: string;
ENIs: number;
Coverage: string; // "vpc", "partial", "failing" or "none"
Traffic: string;
Destination: string;
Action: string;
}
const describe = (f: FlowLog): string =>
`${f.LogDestinationType ?? "cloud-watch-logs"}:${f.LogDestination ?? f.LogGroupName ?? "?"}`;
async function scanRegion(region: string): Promise<Row[]> {
const ec2 = new EC2Client({ region });
// Every flow log in the Region, grouped by the VPC, subnet or ENI it watches.
const byResource = new Map<string, FlowLog[]>();
for await (const page of paginateDescribeFlowLogs({ client: ec2 }, {})) {
for (const f of page.FlowLogs ?? []) {
if (!f.ResourceId || f.FlowLogStatus !== "ACTIVE") continue;
byResource.set(f.ResourceId, [...(byResource.get(f.ResourceId) ?? []), f]);
}
}
// Subnets and network interfaces per VPC, so subnet- or ENI-level logs count as partial coverage.
const childrenOf = new Map<string, string[]>();
const addChild = (vpc: string | undefined, id: string | undefined) => {
if (vpc && id) childrenOf.set(vpc, [...(childrenOf.get(vpc) ?? []), id]);
};
for await (const page of paginateDescribeSubnets({ client: ec2 }, {})) {
for (const s of page.Subnets ?? []) addChild(s.VpcId, s.SubnetId);
}
const eniCount = new Map<string, number>();
for await (const page of paginateDescribeNetworkInterfaces({ client: ec2 }, {})) {
for (const n of page.NetworkInterfaces ?? []) {
addChild(n.VpcId, n.NetworkInterfaceId);
if (n.VpcId) eniCount.set(n.VpcId, (eniCount.get(n.VpcId) ?? 0) + 1);
}
}
const rows: Row[] = [];
for await (const page of paginateDescribeVpcs({ client: ec2 }, {})) {
for (const vpc of page.Vpcs ?? []) {
const id = vpc.VpcId ?? "";
const own = byResource.get(id) ?? [];
const healthy = own.filter((f) => f.DeliverLogsStatus !== "FAILED");
const partial = (childrenOf.get(id) ?? []).some((child) => byResource.has(child));
const coverage = healthy.length ? "vpc" : own.length ? "failing" : partial ? "partial" : "none";
rows.push({
Region: region,
Vpc: id,
Name: vpc.Tags?.find((t) => t.Key === "Name")?.Value ?? "",
Default: vpc.IsDefault ? "yes" : "",
ENIs: eniCount.get(id) ?? 0,
Coverage: coverage,
Traffic: [...new Set(own.map((f) => f.TrafficType ?? ""))].join("+"),
Destination: own.map(describe).join(" "),
Action: "",
});
}
}
// Optional fix: one VPC-level flow log to S3 per VPC with no coverage at all.
const missing = rows.filter((r) => r.Coverage === "none");
if (missing.length && bucketArn) {
const destination = bucketArn.replace("{region}", region);
if (!apply) {
for (const r of missing) r.Action = `would create (${traffic} -> ${destination})`;
} else {
const res = await ec2.send(
new CreateFlowLogsCommand({
ResourceType: "VPC",
ResourceIds: missing.map((r) => r.Vpc), // up to 300 per call for VPCs; batch if you have more
TrafficType: traffic,
LogDestinationType: "s3",
LogDestination: destination,
MaxAggregationInterval: 600,
DestinationOptions: { FileFormat: "parquet", HiveCompatiblePartitions: false, PerHourPartition: false },
TagSpecifications: [{ ResourceType: "vpc-flow-log", Tags: [{ Key: "created-by", Value: "find-vpcs-without-flow-logs" }] }],
ClientToken: randomUUID(),
}),
);
const failed = new Map((res.Unsuccessful ?? []).map((u) => [u.ResourceId ?? "", u.Error?.Message ?? "failed"]));
for (const r of missing) r.Action = failed.get(r.Vpc) ?? "created";
}
}
return rows;
}
function toCsv(rows: Row[]): string {
const cols: (keyof Row)[] = ["Region", "Vpc", "Name", "Default", "ENIs", "Coverage", "Traffic", "Destination", "Action"];
const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of regions) rows.push(...(await scanRegion(region)));
console.table(rows.map(({ Destination, ...shown }) => shown));
const count = (c: string) => rows.filter((r) => r.Coverage === c).length;
console.log(
`${rows.length} VPCs: ${count("vpc")} with a VPC-level flow log, ${count("partial")} partial, ` +
`${count("failing")} failing delivery, ${count("none")} with no flow logs`,
);
const idle = rows.filter((r) => r.Coverage === "none" && r.ENIs === 0).length;
if (idle) console.log(`${idle} of the uncovered VPCs have no network interfaces: consider deleting them instead`);
if (csvPath) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
if (!apply) console.log("Dry run: no flow log was created. Add --bucket-arn to preview and --apply to create.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# Report only, two Regions, with a CSV for the review
AWS_PROFILE=readonly npx tsx find-vpcs-without-flow-logs.ts --regions us-east-1,eu-west-1 --csv vpc-flow-logs.csv
# Preview the fix: one Parquet flow log per uncovered VPC, into a bucket per Region
AWS_PROFILE=netadmin npx tsx find-vpcs-without-flow-logs.ts --regions us-east-1,eu-west-1 \
--bucket-arn "arn:aws:s3:::acme-flow-logs-{region}/vpc/"
# Create them
AWS_PROFILE=netadmin npx tsx find-vpcs-without-flow-logs.ts --regions us-east-1,eu-west-1 \
--bucket-arn "arn:aws:s3:::acme-flow-logs-{region}/vpc/" --apply
Sample output
┌─────────┬─────────────┬─────────────────────────┬──────────────┬─────────┬──────┬───────────┬─────────┬───────────────────────────────────────────────────────────┐
│ (index) │ Region │ Vpc │ Name │ Default │ ENIs │ Coverage │ Traffic │ Action │
├─────────┼─────────────┼─────────────────────────┼──────────────┼─────────┼──────┼───────────┼─────────┼───────────────────────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'vpc-0a1b2c3d4e5f60718' │ 'prod' │ '' │ 64 │ 'vpc' │ 'ALL' │ '' │
│ 1 │ 'us-east-1' │ 'vpc-0b2c3d4e5f6071829' │ 'staging' │ '' │ 21 │ 'partial' │ '' │ '' │
│ 2 │ 'us-east-1' │ 'vpc-1a2b3c4d' │ '' │ 'yes' │ 0 │ 'none' │ '' │ 'would create (ALL -> arn:aws:s3:::acme-flow-logs-us-east-1/vpc/)' │
│ 3 │ 'eu-west-1' │ 'vpc-0c3d4e5f607182930' │ 'data' │ '' │ 12 │ 'failing' │ 'REJECT'│ '' │
│ 4 │ 'eu-west-1' │ 'vpc-0d4e5f60718293a41' │ 'batch' │ '' │ 7 │ 'none' │ '' │ 'would create (ALL -> arn:aws:s3:::acme-flow-logs-eu-west-1/vpc/)' │
└─────────┴─────────────┴─────────────────────────┴──────────────┴─────────┴──────┴───────────┴─────────┴───────────────────────────────────────────────────────────┘
5 VPCs: 1 with a VPC-level flow log, 1 partial, 1 failing delivery, 2 with no flow logs
1 of the uncovered VPCs have no network interfaces: consider deleting them instead
Dry run: no flow log was created. Add --bucket-arn to preview and --apply to create.
IDs are illustrative. data has a flow log that only records rejected traffic and isn’t delivering, which is worse than it looks: the console shows a flow log, so nobody checks. The default VPC in us-east-1 has no network interfaces, so deleting it with the script to find and delete default VPCs you aren’t using is often a better fix than logging it.
Troubleshooting
- An
Unsuccessfulitem with an access error. The bucket policy doesn’t allowdelivery.logs.amazonaws.comto write, or theaws:SourceArncondition names another Region or account. Fix the policy, then re-run with--apply; covered VPCs are skipped. - Coverage stays
failing. For a CloudWatch Logs destination, the IAM role inDeliverLogsPermissionArnis missing or can’t be assumed. You can’t change a flow log’s configuration after creation; delete it and create a new one. UnauthorizedOperationon a describe call. The profile lacks one of the four describe actions; the steps to troubleshoot AWS IAM access denied errors decode the message.- A peered VPC isn’t listed. You can only create flow logs for VPCs in your own account; run the script with a profile in the peer account.
Ask ChatWithCloud instead
For a quick check in one Region, ask ChatWithCloud “Which VPCs in eu-west-1 have no flow logs, and where do the existing flow logs deliver?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result; the page on how ChatWithCloud runs AWS SDK code locally shows each step. It uses one profile and one Region per session and runs generated code without a confirmation step, so connect ChatWithCloud to a read-only AWS profile for audits like this. For a multi-Region CSV and a controlled fix, keep the script.
Frequently asked questions
How do I check if a VPC has flow logs with the AWS CLI?
Run aws ec2 describe-flow-logs --filter Name=resource-id,Values=vpc-0123456789abcdef0. An empty FlowLogs list means there is no VPC-level flow log, though subnets or network interfaces inside it may still have their own.
Should flow logs capture ALL or REJECT traffic?
ALL if you want to investigate incidents, because accepted connections are what show data leaving. REJECT is cheaper and useful for spotting scans against your security groups, but it can’t show what happened after a connection succeeded.
Do VPC flow logs slow down my network?
No. Flow log data is collected outside the path of your network traffic, so it doesn’t affect throughput or latency.
How do I find VPCs without flow logs in every account?
Run the script once per account with a role you can assume in each, and merge the CSVs. AWS Config and Security Hub can also report missing flow logs if you already use them across your organization.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud