This check flags Azure Network Security Group rules that allow inbound FTP (ports 20 and 21) from the public internet. FTP sends credentials and data in cleartext and is a magnet for brute force scanners, so lock the rule down to known IPs or, better yet, replace FTP with SFTP and delete the rule entirely.
FTP refuses to die. Decades after it should have been retired, it still shows up in deployment pipelines, legacy file servers, and vendor integrations that nobody wants to touch. When an Azure Network Security Group exposes FTP straight to 0.0.0.0/0, you have combined one of the oldest insecure protocols with the largest possible attack surface. Lensix raises nsg_openftp when it finds exactly that.
What this check detects
The nsg_openftp check inspects every inbound rule on your Network Security Groups and flags any that:
- Allow traffic (rule action is Allow)
- Target the FTP control port (21) or the FTP data port (20), individually or inside a port range
- Source from a public address, meaning
0.0.0.0/0,*,Internet, or any broad public CIDR
A rule like the one below is a textbook match:
{
"name": "allow-ftp",
"properties": {
"priority": 200,
"direction": "Inbound",
"access": "Allow",
"protocol": "Tcp",
"sourceAddressPrefix": "*",
"sourcePortRange": "*",
"destinationPortRange": "20-21",
"destinationAddressPrefix": "*"
}
}
Note: An NSG is Azure's stateful packet filter. You attach it to a subnet or a NIC, and it evaluates inbound and outbound rules by priority. The lowest priority number wins, so a permissive allow-ftp rule at priority 200 overrides a tighter deny at priority 300.
Why it matters
Plain FTP has no transport encryption. The control channel carries your username and password as readable text, and the data channel carries your files the same way. Anyone positioned between the client and server, whether on a shared network segment, a compromised hop, or a misconfigured load balancer, can read both.
Exposing it to the internet makes the problem far worse:
- Credential harvesting. Automated scanners constantly sweep public IP ranges for open port 21. Once found, they run dictionary attacks against common usernames. Because the login is cleartext, a single captured packet can hand over working credentials.
- Anonymous access. Plenty of FTP daemons ship with anonymous login enabled. An open NSG plus a default config means anyone can list and pull files without authenticating at all.
- Data exfiltration and staging. Attackers love writable FTP shares. They become drop zones for stolen data or malware distribution points that abuse your bandwidth and reputation.
- Active mode firewall headaches. FTP's active mode opens a second connection back from port 20, which often forces teams to widen NSG rules even further to make transfers work. Each widening adds risk.
Warning: Compliance frameworks treat cleartext credential transmission as a finding on its own. PCI DSS, HIPAA, and SOC 2 auditors will flag a public FTP listener regardless of whether it has been breached, so this is both a security and an audit problem.
How to fix it
The right fix depends on whether you still need FTP at all. In most cases you do not, and the cleanest remediation is to remove the rule and move the workload to SFTP. If you genuinely need FTP for now, restrict the source to known addresses.
Step 1: Find the offending rules
List inbound rules across an NSG and look for anything touching ports 20 or 21:
az network nsg rule list \
--resource-group my-rg \
--nsg-name my-nsg \
--query "[?direction=='Inbound' && access=='Allow'].{name:name, priority:priority, ports:destinationPortRange, source:sourceAddressPrefix}" \
--output table
Step 2 (preferred): Remove the rule and retire FTP
Danger: Deleting an NSG rule cuts off whatever traffic depended on it. Confirm no active integration relies on this FTP endpoint before running the command, otherwise file transfers will start failing immediately.
az network nsg rule delete \
--resource-group my-rg \
--nsg-name my-nsg \
--name allow-ftp
If you need a replacement, Azure Storage offers a managed SFTP endpoint on Blob storage, which removes the need to run and patch an FTP VM at all:
az storage account update \
--resource-group my-rg \
--name mystorageacct \
--enable-sftp true
Step 3 (if FTP must stay): Restrict the source
Replace the wide-open source with the specific addresses that legitimately need access. Update the existing rule rather than adding a new one so priorities do not collide:
az network nsg rule update \
--resource-group my-rg \
--nsg-name my-nsg \
--name allow-ftp \
--source-address-prefixes 203.0.113.10 198.51.100.0/24 \
--destination-port-ranges 20 21
If you cannot enumerate the client IPs, do not punch a hole in the NSG. Front the endpoint with a VPN gateway or Azure Bastion and keep FTP off the public internet entirely.
Tip: Use a service tag instead of raw CIDRs when the consumer is another Azure service. For example, setting the source to AzureCloud or a region-scoped tag is more maintainable than tracking IP ranges that change over time.
Step 4: Verify the change
az network nsg rule show \
--resource-group my-rg \
--nsg-name my-nsg \
--name allow-ftp \
--query "{source:sourceAddressPrefix, sources:sourceAddressPrefixes, ports:destinationPortRanges}"
How to prevent it from happening again
Manual cleanup only holds until the next person copies an old template. Catch these rules before they reach production.
Azure Policy
Deny NSG rules that open FTP ports to the internet at deployment time. A custom policy can target the rule properties directly:
{
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", "equals": "Allow" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", "equals": "Inbound" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", "in": ["20", "21", "20-21"] },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", "in": ["*", "0.0.0.0/0", "Internet"] }
]
},
"then": { "effect": "deny" }
}
Terraform guardrails
If you manage NSGs in Terraform, scan plans with a policy-as-code tool like Checkov or Conftest before apply. A simple Conftest rule in Rego rejects the pattern:
cat <<'EOF' > policy/ftp.rego
package main
deny[msg] {
rule := input.resource.azurerm_network_security_rule[name]
rule.access == "Allow"
rule.direction == "Inbound"
rule.source_address_prefix == "*"
contains(rule.destination_port_range, "21")
msg := sprintf("NSG rule %s exposes FTP to the internet", [name])
}
EOF
conftest test plan.json
Tip: Wire the scan into your pull request checks so a failing FTP rule blocks the merge. Catching it in CI costs seconds. Catching it after an incident costs a lot more.
Lensix runs nsg_openftp continuously against your live Azure environment, so even rules created outside your pipeline, by a console click or an emergency change, get surfaced before an attacker finds them.
Best practices
- Default to SFTP or FTPS. If you must move files, use an encrypted protocol. Azure Storage SFTP gives you a managed endpoint with no VM to maintain.
- Never source from
*on management or file-transfer ports. Scope every inbound rule to the narrowest set of addresses that needs it. - Prefer private connectivity. Reach internal services through VPN, ExpressRoute, or private endpoints instead of opening them to the internet.
- Review NSG rules on a schedule. Temporary rules become permanent. Audit them regularly and delete anything without a clear owner and purpose.
- Tag and document exceptions. If a legacy FTP rule cannot be removed yet, record why, who owns it, and the date it will be retired.
FTP exposed to the internet is rarely a deliberate decision. It is usually leftover scaffolding from a faster era of networking. Treat the nsg_openftp finding as a prompt to retire the protocol for good rather than just narrowing the rule and moving on.

