Network Security Documentation Template (VPC, Firewall)
A template for documenting VPC, firewall, and DNS security rules inventory.
Overview
The network is the first thing an attacker probes and usually the last thing anyone documents. Misconfigured firewall rules, overly permissive security groups, and stale DNS records are common entry points for attackers — and they’re almost impossible to catch without an inventory. This template gives you that inventory: every rule with a justification, an owner, and a review date, so you can audit the network instead of guessing at it.
The structure works for a quarterly security review, for onboarding a new service that needs network access, or for proving compliance when an auditor asks “who approved this rule and why does it exist?”
When to Use
Use this resource when:
- Onboarding a new service that requires network access rules
- Conducting a quarterly security audit or preparing for a penetration test
- Migrating infrastructure to a new VPC or cloud provider
- Inheriting an environment where nobody can say why a rule exists
Skip it for a single-purpose dev sandbox or a proof-of-concept that gets torn down in days — the review overhead costs more than the risk there.
Solution
# Network Security Inventory: `<Environment>`
## 1. Scope
| Field | Value |
|-------|-------|
| Environment | `prod / staging / dev` |
| Cloud Provider | `AWS / GCP / Azure / On-prem` |
| Network CIDR | `10.0.0.0/16` |
| Last Reviewed | `YYYY-MM-DD` |
| Reviewer | `@security-team` |
## 2. VPC / Network Segmentation
| Segment | CIDR | Purpose | Internet Access | Peering |
|---------|------|---------|-----------------|---------|
| Public subnet | `10.0.1.0/24` | Load balancers, bastion | Yes (egress only) | None |
| Application subnet | `10.0.2.0/24` | Microservices | No | Peered to shared services |
| Database subnet | `10.0.3.0/24` | PostgreSQL, Redis | No | Peered to shared services |
| Management subnet | `10.0.4.0/24` | CI/CD runners, monitoring | Yes (NAT) | None |
| Shared services | `10.1.0.0/24` | SSO, logging, secrets | No | Peered to all VPCs |
## 3. Firewall / Security Group Rules
### 3.1. Inbound Rules
| Source | Protocol | Port | Destination | Justification | Last Reviewed |
|--------|----------|------|-------------|---------------|---------------|
| `0.0.0.0/0` | TCP | 443 | Public ALB | Public HTTPS | `YYYY-MM-DD` |
| `10.0.2.0/24` | TCP | 5432 | Database subnet | Application → DB | `YYYY-MM-DD` |
| `10.0.4.0/24` | TCP | 22 | Bastion host | Admin access | `YYYY-MM-DD` |
| `VPN CIDR` | TCP | 3389 | Windows jump host | Admin access | `YYYY-MM-DD` |
### 3.2. Outbound Rules
| Destination | Protocol | Port | Source | Justification | Last Reviewed |
|-------------|----------|------|--------|---------------|---------------|
| `0.0.0.0/0` | TCP | 443 | All subnets | Software updates, APIs | `YYYY-MM-DD` |
| `0.0.0.0/0` | TCP | 53 | All subnets | DNS resolution | `YYYY-MM-DD` |
| `0.0.0.0/0` | UDP | 53 | All subnets | DNS resolution | `YYYY-MM-DD` |
| `SaaS IP range` | TCP | 443 | Application subnet | Third-party API | `YYYY-MM-DD` |
## 4. DNS Configuration
| Record | Type | Value | TTL | Purpose | Secured |
|--------|------|-------|-----|---------|---------|
| `api.example.com` | A | ALB IP | 60s | Public API | DNSSEC enabled |
| `internal.example.com` | A | Private IP | 300s | Internal services | Private zone only |
| `cdn.example.com` | CNAME | `cdn.provider.com` | 300s | Asset delivery | HTTPS enforced |
## 5. Network Access Controls
| Control | Implementation | Scope | Status |
|---------|----------------|-------|--------|
| DDoS protection | AWS Shield / Cloud Armor / Azure DDoS | Public endpoints | Active |
| WAF | AWS WAF / Cloudflare / ModSecurity | Public endpoints | Active |
| Intrusion detection | Suricata / GuardDuty / Azure Sentinel | VPC flow logs | Active |
| VPN / Zero Trust | WireGuard / Zscaler / BeyondCorp | All admin access | Active |
| Private endpoints | VPC endpoints / PrivateLink / Private Link | AWS / Azure services | Active |
## 6. Change Log
| Date | Change | Requester | Approved By | Ticket |
|------|--------|-----------|-------------|--------|
| `YYYY-MM-DD` | Opened port 5432 for new analytics service | `data-team` | `security-team` | `SEC-123` |
| `YYYY-MM-DD` | Removed SSH from public subnet | `sre-team` | `security-team` | `SEC-124` |
How to Fill Each Section
You can fill every table with a command or console view you already have. Fill it from the source of truth, not from memory.
| Section | What to capture | Where to get it |
|---|---|---|
| Scope | Environment, provider, CIDR, reviewer | VPC details page or aws ec2 describe-vpcs |
| Segmentation | One row per subnet/VPC with its purpose | aws ec2 describe-subnets, gcloud compute networks subnets list, Azure portal → VNets |
| Inbound rules | Source, port, destination, justification | aws ec2 describe-security-groups, gcloud compute firewall-rules list, az network nsg rule list |
| Outbound rules | Same fields, for egress | Same commands — egress rules hide in the same output |
| DNS | Record, type, value, TTL, secured | Route 53 / Cloud DNS / registrar export, or dig/nslookup for spot checks |
| Access controls | DDoS, WAF, IDS, VPN, private endpoints status | Each service’s console; mark anything absent explicitly rather than leaving the row out |
| Change log | Date, change, requester, approver, ticket | Your ticketing system; if a change has no ticket, that’s a finding in itself |
The one column people skip is Justification. It’s also the column that decides whether the inventory is worth anything six months from now. “Opened for deploy debugging in March” is a justification; “needed it” is not. And when a command returns something that doesn’t map to any table row, that’s the interesting case: it usually means the environment grew a control, or a hole, the inventory never knew about.
Worked Example
Here’s what section 3.1 looks like filled in for a mid-size SaaS production environment:
| Source | Protocol | Port | Destination | Justification | Last Reviewed |
|---|---|---|---|---|---|
0.0.0.0/0 | TCP | 443 | alb-orders-prod | Public HTTPS entry | 2026-09-01 |
10.0.2.0/24 | TCP | 5432 | rds-orders-primary | Orders API → Postgres | 2026-09-01 |
10.0.4.0/24 | TCP | 22 | bastion-prod-01 | Admin SSH via bastion | 2026-06-15 |
203.0.113.0/24 | TCP | 443 | alb-orders-prod | Partner API allowlist (Contract P-118) | 2026-08-20 |
Two things worth copying from the example: every row names a concrete resource (not “the database”), and the partner rule carries its contract reference, so renewing or killing it is one ticket instead of an archaeology project. Keep obvious placeholders like VPN CIDR until the real value exists; a half-accurate CIDR is more dangerous than a visible placeholder because it looks finished.
Explanation
The template treats your network as a defense-in-depth system. With segmentation, a compromised subnet doesn’t hand the attacker a path to the database. Every rule requires a justification and a review date so stale rules don’t persist forever — a rule nobody can explain is a rule that should be closed. DNS gets overlooked in most reviews, but a hijacked or stale record redirects traffic to attacker-controlled infrastructure just as effectively as an open port.
Notice the asymmetry: the public subnet is the only one with inbound internet access, and nothing but the load balancer terminates there. If your inventory shows an app or database rule reaching 0.0.0.0/0 inbound, that’s the first row to review.
When you document segmentation decisions, pair this inventory with the network segmentation policy template — the inventory records what exists, the policy records what’s allowed to exist. In practice they drift apart within weeks.
What a First Review Usually Finds
Expect the first pass over a real environment to surface the same handful of problems:
- Admin ports open to the world: SSH or RDP reachable from
0.0.0.0/0, usually leftover from a provisioning shortcut. Move them behind the bastion or VPN. - Orphaned rules: ports opened for a service that no longer exists, often from a project that shipped or died without a cleanup ticket.
- One flat network: a single subnet carrying web, app, and database traffic, which makes every rule review start from zero.
- Egress nobody looked at: inbound rules get scrutiny while outbound stays wide open — fine for DNS and updates, but anything else needs a justification.
- DNS with no owner: records pointing at decommissioned IPs, or nobody knowing who created a critical A record.
Each finding maps to a row in the change log and a ticket with an owner. The inventory exists to make them visible; the fixes happen through tickets.
Variants
| Environment | Approach | Notes |
|---|---|---|
| AWS | VPC + Security Groups + NACLs | Use NACLs for subnet-level rules, SGs for instance-level |
| GCP | VPC Firewall Rules + Cloud Armor | Firewall rules are stateful; use service accounts for identity |
| Azure | NSGs + Azure Firewall | NSGs apply to subnets or NICs; Azure Firewall for central logging |
| Kubernetes | Network Policies + Cilium / Calico | Enforce namespace isolation; default-deny is safest |
| Hybrid / On-prem | Physical firewall + SD-WAN | Document both logical and physical paths |
The shape of the inventory stays the same across providers; only the field names change. For Kubernetes, a “subnet” is a namespace and a “security group” is a NetworkPolicy — if you’re running containers at scale, the container security baseline template covers the complementary controls at the image and runtime layers.
What Works
- Default-deny everything, then allow only what has a documented justification.
- Review firewall rules quarterly and delete whatever lost its justification.
- Allow
0.0.0.0/0inbound only to a load balancer that terminates TLS — never to instances. - Turn on VPC flow logs and keep at least 90 days; they’re the only record you’ll have of lateral movement.
- Reach cloud services through private endpoints instead of the public internet.
Common Mistakes
- Allowing SSH or RDP from
0.0.0.0/0instead of routing admin access through a bastion or VPN. - Reusing one security group across production and staging, which blurs every boundary you’ve drawn.
- Leaving temporary rules behind after an incident or debugging session — they’re the ones nobody remembers opening.
- Collecting VPC flow logs but never looking at them, so lateral movement goes unseen during a breach.
- Hardcoding IP addresses in DNS instead of CNAMEs to load balancers.
Review Cadence and Ownership
An inventory nobody maintains is worse than none — it gives you confident answers that are wrong.
- Quarterly review: go through every rule whose
Last Reviewedis older than 90 days. Each gets one of three outcomes: keep (renew justification), remove (file the ticket), or flag for risk acceptance. - Owner per row: the team that requested the rule owns its justification. Can’t justify it? Delete the rule rather than renew it.
- Risk acceptance is a documented decision: if a rule can’t be removed but isn’t ideal (a vendor requires a fixed IP allowlist, say), record the risk owner and an expiry date for the exception — don’t let “temporary” become permanent by silence.
- Change log discipline: every entry ties a rule change to a ticket. If the change log and the live configuration disagree, trust the live config and fix the process that let them drift.
- Pair it with adjacent checklists: the endpoint security checklist covers the host side of the same review, and the CI/CD pipeline security template covers the deploy path that keeps touching these rules.
Advanced Solutions
Automated security group audit with AWS CLI
Export and analyze all security group rules to find overly permissive configurations:
import boto3
import csv
from dataclasses import dataclass
from typing import List
@dataclass
class SecurityGroupFinding:
group_id: str
group_name: str
direction: str # ingress or egress
protocol: str
from_port: int
to_port: int
cidr: str
severity: str # critical / warning / ok
vpc_id: str
class SecurityGroupAuditor:
def __init__(self, region: str = "us-east-1"):
self.ec2 = boto3.client("ec2", region_name=region)
def audit_all_security_groups(self) -> List[SecurityGroupFinding]:
"""Audit all security groups for overly permissive rules."""
findings = []
response = self.ec2.describe_security_groups()
for sg in response["SecurityGroups"]:
for rule_type in ["IpPermissions", "IpPermissionsEgress"]:
direction = "ingress" if rule_type == "IpPermissions" else "egress"
for rule in sg.get(rule_type, []):
for ip_range in rule.get("IpRanges", []):
cidr = ip_range.get("CidrIp", "N/A")
from_port = rule.get("FromPort", 0)
# 0.0.0.0/0 on non-web ports is critical; on web ports it's a warning
if cidr != "0.0.0.0/0":
severity = "ok"
elif direction == "ingress" and from_port not in (80, 443):
severity = "critical"
else:
severity = "warning"
findings.append(SecurityGroupFinding(
group_id=sg["GroupId"],
group_name=sg["GroupName"],
direction=direction,
protocol=rule.get("IpProtocol", "all"),
from_port=from_port,
to_port=rule.get("ToPort", 0),
cidr=cidr,
severity=severity,
vpc_id=sg.get("VpcId", "default"),
))
return findings
def export_findings(self, output_file: str) -> None:
"""Export findings to CSV for audit trail."""
findings = self.audit_all_security_groups()
with open(output_file, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"Group ID", "Group Name", "Direction", "Protocol",
"From Port", "To Port", "CIDR", "Severity", "VPC ID"
])
for finding in findings:
writer.writerow([
finding.group_id, finding.group_name, finding.direction,
finding.protocol, finding.from_port, finding.to_port,
finding.cidr, finding.severity, finding.vpc_id,
])
risky = [f for f in findings if f.severity == "critical"]
print(f"Total rules: {len(findings)}")
print(f"Critical rules: {len(risky)}")
for r in risky:
print(
f" RISK: {r.group_name} ({r.direction})"
f" - {r.protocol}:{r.from_port} from {r.cidr}"
)
# Example usage
auditor = SecurityGroupAuditor(region="us-east-1")
auditor.export_findings("sg-audit-report.csv")
VPC flow log analysis for lateral movement detection
Parse VPC flow logs to detect suspicious traffic patterns:
#!/bin/bash
set -euo pipefail
# Query VPC flow logs for rejected traffic in the last 24 hours
LOG_GROUP="/aws/vpc/flow-logs"
START_TIME=$(date -d '24 hours ago' +%s)000
END_TIME=$(date +%s)000
aws logs filter-log-events \
--log-group-name "$LOG_GROUP" \
--start-time "$START_TIME" \
--end-time "$END_TIME" \
--filter-pattern "REJECT" \
--query 'events[*].message' \
--output text | while read -r line; do
# Flow log fields (space separated):
# version account-id srcaddr dstaddr srcport dstport
# protocol packets bytes start end action log-status
SRC=$(echo "$line" | awk '{print $4}')
DST=$(echo "$line" | awk '{print $5}')
DSTPORT=$(echo "$line" | awk '{print $7}')
ACTION=$(echo "$line" | awk '{print $13}')
echo "REJECTED: $SRC -> $DST:$DSTPORT ($ACTION)"
done
# Aggregate by source IP to find scanning attempts
echo ""
echo "=== Top Source IPs by Rejected Connections ==="
aws logs filter-log-events \
--log-group-name "$LOG_GROUP" \
--start-time "$START_TIME" \
--end-time "$END_TIME" \
--filter-pattern "REJECT" \
--query 'events[*].message' \
--output text | \
awk '{print $4}' | \
sort | uniq -c | sort -rn | head -20
Kubernetes network policy enforcement with Cilium
Define and verify default-deny network policies across all namespaces:
# default-deny-all.yaml - Apply to every namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# allow-dns.yaml - Explicitly allow DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: production
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# allow-frontend-to-backend.yaml - Specific service communication
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
#!/bin/bash
# Verify all namespaces have default-deny policies
set -euo pipefail
NAMESPACES=$(kubectl get namespaces -o jsonpath='{.items[*].metadata.name}')
for ns in $NAMESPACES; do
POLICIES=$(kubectl get networkpolicy -n "$ns" -o json | \
jq -r '.items[] | select(.metadata.name | test("default-deny")) | .metadata.name')
if [ -z "$POLICIES" ]; then
echo "WARNING: Namespace '$ns' has no default-deny network policy"
else
echo "OK: Namespace '$ns' has policy: $POLICIES"
fi
done
Companion code: the network-security-template companion repo has these scripts as runnable files.
See Also
- AWS Security Groups documentation — reference for rules, limits, and defaults.
- AWS VPC Flow Logs — setup and record format.
- GCP VPC firewall rules — implied rules and priority semantics.
- Azure NSG overview — NSG evaluation order.
- NIST SP 800-41 — firewall policy guidelines from NIST.
- Zero Trust architecture guide — the broader model this inventory feeds into.
Frequently Asked Questions
How do I audit existing security groups quickly?
Use aws ec2 describe-security-groups (or equivalent) and export to CSV. Then sort by the rules that allow inbound 0.0.0.0/0 and cross-reference each one with change tickets. If a rule has no justification or hasn't been reviewed in over a year, mark it for removal and notify the owner team.
Should I use Network ACLs or Security Groups?
Both. Security groups are stateful, so a rule you open automatically lets the replies back in; they work at the instance level and stay your primary control. NACLs are stateless and apply to subnets, so use them as a coarse-grained backup defense. A NACL, for example, can block a port entirely at the subnet level even when a security group mistakenly allows it.
How do I secure DNS records?
Turn on DNSSEC for public zones; it stops cache poisoning. Give records that might need fast changes a short TTL (60–300s). Watch for unauthorized record changes through your DNS provider's audit logs. Inside the VPC, run internal DNS from private zones and restrict zone transfers to authorized servers.
How often should I review firewall rules?
Quarterly is the cadence that holds up in practice — monthly reviews tend to get skipped and annual ones let too much rot accumulate. Anything touching 0.0.0.0/0 inbound deserves a look on every review, and temporary rules should carry an expiry date the moment they're opened.
What do I do with a rule nobody can justify?
Start by checking flow logs for actual traffic over the last 90 days. If it's quiet, remove the rule and watch for complaints — with the change logged, rolling back is trivial. If it carries traffic, trace the source and ask that owner to write the justification or migrate to a better-scoped rule.
Related Resources
Auto-Scaling Policy Template: HPA-Ready
Copy-ready auto-scaling policy template with scale-up/down thresholds, cooldowns, cost controls, and Terraform + Kubernetes HPA examples.
DocBackup & Restore Verification Template
A template for documenting database and file backup verification procedures.
DocCross-Region Failover Test Template
A runbook-style template for planning, executing, and documenting a cross-region failover test with go/no-go gates, abort criteria, and failback.
DocDeployment Checklist Template
A copy-paste verification checklist for safe production deployments, from pre-merge tests to post-release monitoring.
DocNetwork Segmentation Policy Template
A template for documenting network security zones, segmentation rules, and traffic controls between environments and tenants.
DocEndpoint Device Security Checklist Template
A checklist template for hardening laptops, workstations, and mobile devices that access corporate data and systems.