OCI Network Security Groups Should Not Allow Public SSH
More Info:
Network Security Groups should not allow SSH (port 22) access from 0.0.0.0/0. Public SSH endpoints are continuously targeted by automated brute-force attacks.
Risk Level
Critical
Address
Compliance, Security
Compliance Standards
- APRA CPS 234 (Australia)
- BSI C5 (Germany)
- Brazil LGPD
- CCPA / CPRA (California)
- CIS Critical Security Controls v8
- CMMC 2.0
- CSA Cloud Controls Matrix v4
- DPDPA
- Digital Operational Resilience Act (EU)
- ISO/IEC 27017
- ISO/IEC 27018
- ISO/IEC 27701
- KSA PDPL
- MAS Technology Risk Management (Singapore)
- MITRE ATT&CK (Cloud)
- NIS2 Directive
- NIST
- NIST SP 800-171
- NYDFS 23 NYCRR 500
- SWIFT Customer Security Controls Framework
- Sarbanes-Oxley IT General Controls
- Securities and Exchange Board of India (SEBI) - Cloud Security Adoption Framework
- UK NCSC Cyber Assessment Framework
Triage and Remediation
- Remediation
Remediation
Using Console
To remediate “OCI Network Security Groups Should Not Allow Public SSH Access” using the OCI Console:
-
Identify the offending NSG rule
- In the OCI Console, open the navigation menu.
- Go to Logging & Monitoring → Security → Cloud Guard (or Security Zones / your monitoring tool) and open the detector or finding that reports:
- “Network Security Groups Should Not Allow Public SSH Access” (or similar).
- From the finding details, note:
- The Compartment
- The VCN
- The Network Security Group (NSG) name or OCID
- Click the NSG link if available, or navigate manually (next steps).
-
Navigate to the Network Security Group
- Open the navigation menu.
- Go to Networking → Virtual Cloud Networks.
- Select the compartment where the VCN/NSG resides.
- Click the VCN that contains the NSG.
- In the VCN details page, under Resources, click Network Security Groups.
- Click the NSG identified in the finding.
-
Review ingress (inbound) rules
- In the NSG details page, click the Ingress Rules tab.
- Look for any rule that:
- Uses Destination Port Range =
22(or22-22) - AND Source Type =
CIDR - AND Source CIDR is:
0.0.0.0/0(IPv4 public)- or
::/0(IPv6 public)
- AND protocol is TCP (or All protocols).
- Uses Destination Port Range =
-
Decide remediation approach You have two secure options:
- Restrict SSH to trusted IP ranges (e.g., your office VPN or bastion host):
- Replace
0.0.0.0/0with a specific CIDR, e.g.203.0.113.10/32.
- Replace
- Remove direct SSH entirely (recommended when using OCI Bastion, Session Manager, or other jump hosts):
- Delete the rule that allows SSH from the internet.
- Restrict SSH to trusted IP ranges (e.g., your office VPN or bastion host):
-
Edit or delete the problematic rule
- On the Ingress Rules tab:
- To delete:
- Click the menu (⋯) or Delete icon next to the SSH rule.
- Confirm deletion.
- To edit (if you need SSH but only from specific sources):
- Click Edit (if available) or delete and recreate:
- If editing:
- Change Source Type to
CIDR. - Set Source CIDR to your trusted IP/CIDR (e.g.
198.51.100.0/24). - Ensure IP Protocol is
TCP. - Ensure Destination Port Range is
22(or a custom SSH port if you changed it). - Click Save Changes.
- Change Source Type to
- If recreating:
- Click Add Ingress Rule.
- Set:
- Source Type:
CIDR - Source CIDR: your secure range (e.g., VPN range)
- IP Protocol:
TCP - Destination Port Range:
22 - Optionally set Stateless =
No(default).
- Source Type:
- Click Add Ingress Rule.
- If editing:
- Click Edit (if available) or delete and recreate:
- To delete:
- On the Ingress Rules tab:
-
Check for IPv6 exposure (if applicable)
- Still on Ingress Rules, confirm there is no rule with:
- Source CIDR =
::/0 - Protocol
TCP - Destination port
22.
- Source CIDR =
- If present, remove or restrict it the same way as for IPv4.
- Still on Ingress Rules, confirm there is no rule with:
-
Validate remediation in Monitoring/Cloud Guard
- Go back to Cloud Guard → Detector Findings (or your monitoring view).
- Locate the original finding.
- After a short interval (detection schedule), the finding should:
- Move to Resolved (or similar status),
- Or disappear from the active list.
- If it still persists, confirm again that:
- No NSG ingress rule allows port 22 from
0.0.0.0/0or::/0. - No “All protocols / All ports” rule is using
0.0.0.0/0or::/0for the same NSG.
- No NSG ingress rule allows port 22 from
-
(Optional) Add safer access method
- If you removed public SSH, consider:
- OCI Bastion:
- Navigation: Identity & Security → Bastion.
- Create a bastion in the same VCN and subnet, then use it for SSH sessions.
- Or a VPN/Private connectivity plus NSG rules that only allow SSH from that private network.
- OCI Bastion:
- If you removed public SSH, consider:
If you share the specific rule format (screenshot or text), I can give you the exact field values to use when editing it.
Using CLI
Below are concise, step‑by‑step OCI CLI instructions to find and fix NSG rules that allow public SSH (TCP/22 from 0.0.0.0/0).
Assumptions:
- You have OCI CLI installed and configured.
- You know the
compartment-ocid(and optionallyvcn-id).
1. List Network Security Groups (NSGs)
oci network nsg list \
--compartment-id <compartment-ocid> \
--all
Note the id of the NSG(s) you want to check.
Optionally filter by VCN:
oci network nsg list \
--compartment-id <compartment-ocid> \
--vcn-id <vcn-ocid> \
--all
2. List Security Rules in Each NSG
For each NSG:
oci network nsg rules list \
--network-security-group-id <nsg-ocid> \
--all \
--output table
You’re looking for ingress rules with:
direction = INGRESSprotocol = 6(TCP)source = 0.0.0.0/0(or::/0for IPv6)tcpOptions.destinationPortRange.min = 22and.max = 22(or range covering 22)
Example JSON view:
oci network nsg rules list \
--network-security-group-id <nsg-ocid> \
--all \
--output json
3. Remove or Restrict the Public SSH Rule
You have two main options:
Option A – Delete the offending rule
You must know the rule defined in the NSG (i.e., its isStateless, protocol, source, tcpOptions, etc.), because OCI NSG rules are updated by replacing the full rule set.
- Get current rules into a file:
oci network nsg rules list \
--network-security-group-id <nsg-ocid> \
--all \
--output json > current_rules.json
-
Edit
current_rules.json:- Remove any rule object that:
- is
direction: "INGRESS" - and matches
protocol: "6"with TCP port 22 - and has
source: "0.0.0.0/0"(or::/0).
- is
Keep only the safe rules. Save as
updated_rules.json.The JSON to send to
updatemust be just the array of rules, e.g.:{"securityRules": [{"direction": "INGRESS","isStateless": false,"protocol": "6","source": "10.0.0.0/16","sourceType": "CIDR_BLOCK","tcpOptions": {"destinationPortRange": {"min": 443,"max": 443}}}// ...other safe rules...]} - Remove any rule object that:
-
Apply the new rule set:
oci network nsg rules update \
--network-security-group-id <nsg-ocid> \
--security-rules file://updated_rules.json
This removes the public SSH rule from that NSG.
Option B – Restrict SSH to a specific source (e.g., your office IP range)
Instead of deleting, you can adjust the source from 0.0.0.0/0 to a trusted CIDR, like 203.0.113.0/24.
- Same as above, export rules:
oci network nsg rules list \
--network-security-group-id <nsg-ocid> \
--all \
--output json > current_rules.json
-
In
current_rules.json:- Find the rule with:
direction: "INGRESS"protocol: "6"source: "0.0.0.0/0"- TCP port 22
- Change
sourceto your allowed CIDR, for example:
{"direction": "INGRESS","isStateless": false,"protocol": "6","source": "203.0.113.0/24","sourceType": "CIDR_BLOCK","tcpOptions": {"destinationPortRange": {"min": 22,"max": 22}}}Save as
updated_rules.jsonin the same wrapper structure:{"securityRules": [// ...modified and other rules...]} - Find the rule with:
-
Apply:
oci network nsg rules update \
--network-security-group-id <nsg-ocid> \
--security-rules file://updated_rules.json
4. Verify That Public SSH Is Blocked
Re-list NSG rules and confirm there is no ingress rule with source 0.0.0.0/0 (or ::/0) and TCP destination port 22:
oci network nsg rules list \
--network-security-group-id <nsg-ocid> \
--all \
--output table
Optionally use jq:
oci network nsg rules list \
--network-security-group-id <nsg-ocid> \
--all \
--output json | \
jq '.data[] | select(.direction=="INGRESS" and .protocol=="6" and .source=="0.0.0.0/0" and .tcp-options.destination-port-range.min<=22 and .tcp-options.destination-port-range.max>=22)'
If that command returns nothing, your NSG no longer allows public SSH.
If you share a sample oci network nsg rules list JSON output, I can craft the exact updated_rules.json you should apply.
Using Python
Below is a practical way to detect and remediate “public SSH” from OCI Network Security Groups using Python and the OCI SDK.
Assumptions:
- You already have:
- OCI Python SDK installed:
pip install oci - Config file at
~/.oci/config(or equivalent) with a profile (e.g.DEFAULT)
- OCI Python SDK installed:
- You want to:
- Find NSG rules that allow SSH (TCP/22) from
0.0.0.0/0 - Remove or adjust them programmatically
- Find NSG rules that allow SSH (TCP/22) from
1. Decide your remediation strategy
You have two main options:
-
Delete the offending rule(s)
– safest default if you don’t know the intended source. -
Restrict CIDR instead of deleting (e.g. change
0.0.0.0/0tox.y.z.0/24)
– safer operationally if you know the correct IP ranges.
The script below shows both, with a toggle.
2. Python script: identify & remediate NSG rules
import oci
from oci.core import VirtualNetworkClient
from oci.core.models import (
UpdateNetworkSecurityGroupSecurityRulesDetails,
UpdateSecurityRuleDetails,
TcpOptions
)
# ========= USER CONFIGURATION =========
PROFILE_NAME = "DEFAULT" # oci config profile
COMPARTMENT_OCID = "<your_compartment_ocid>" # or leave None to scan entire tenancy (with recursion)
DRY_RUN = True # True = only report; False = actually remediate
RESTRICT_INSTEAD_OF_DELETE = False # True = modify CIDR instead of deleting rule
NEW_ALLOWED_CIDR = "203.0.113.0/24" # used only if RESTRICT_INSTEAD_OF_DELETE
# =====================================
def is_ssh_rule_from_anywhere(rule):
"""
Returns True if:
- INGRESS rule
- protocol TCP (6)
- source 0.0.0.0/0
- destination port range includes 22
"""
if rule.direction != "INGRESS":
return False
# protocol '6' is TCP, 'all' = 'all' or 'all' numeric, but we care about explicit '6'
if rule.protocol not in ("6", "all"):
return False
if getattr(rule, "source", None) != "0.0.0.0/0":
return False
# ssh is TCP/22
tcp_opts = getattr(rule, "tcp_options", None)
if tcp_opts is None:
# For "all ports", this also counts as exposing SSH
return True
dest_range = tcp_opts.destination_port_range
if dest_range is None:
# Also "all ports" within TCP
return True
# Check if 22 is within the defined port range
if dest_range.min <= 22 <= dest_range.max:
return True
return False
def main():
config = oci.config.from_file("~/.oci/config", PROFILE_NAME)
identity_client = oci.identity.IdentityClient(config)
vcn_client = VirtualNetworkClient(config)
tenancy_id = config["tenancy"]
# If COMPARTMENT_OCID is None, enumerate all compartments in tenancy
compartments_to_scan = []
if COMPARTMENT_OCID:
compartments_to_scan.append(COMPARTMENT_OCID)
else:
# recursive list of all compartments
response = oci.pagination.list_call_get_all_results(
identity_client.list_compartments,
tenancy_id,
compartment_id_in_subtree=True
)
compartments_to_scan = [c.id for c in response.data] + [tenancy_id]
print(f"Scanning compartments: {compartments_to_scan}")
for compartment_id in compartments_to_scan:
nsgs = oci.pagination.list_call_get_all_results(
vcn_client.list_network_security_groups,
compartment_id=compartment_id
).data
for nsg in nsgs:
nsg_id = nsg.id
nsg_name = nsg.display_name
rules_resp = vcn_client.list_network_security_group_security_rules(nsg_id)
rules = rules_resp.data
offending_rules = [r for r in rules if is_ssh_rule_from_anywhere(r)]
if not offending_rules:
continue
print(f"\n[FOUND] NSG: {nsg_name} ({nsg_id}) has public SSH rules:")
for r in offending_rules:
print(f" - Rule ID: {r.id}, protocol: {r.protocol}, source: {r.source}, direction: {r.direction}")
if DRY_RUN:
print(" DRY_RUN=True => not changing anything.")
continue
# Build new rules list: either remove or modify the offending ones
new_rules = []
for r in rules:
if r in offending_rules:
if RESTRICT_INSTEAD_OF_DELETE:
# Modify source CIDR only; keep the rest
print(f" Modifying rule {r.id} source from {r.source} to {NEW_ALLOWED_CIDR}")
updated = UpdateSecurityRuleDetails(
id=r.id,
direction=r.direction,
protocol=r.protocol,
description=r.description,
is_stateless=r.is_stateless,
source_type=getattr(r, "source_type", None),
source=NEW_ALLOWED_CIDR,
destination=r.destination if hasattr(r, "destination") else None,
destination_type=getattr(r, "destination_type", None),
tcp_options=r.tcp_options,
udp_options=r.udp_options,
icmp_options=r.icmp_options
)
new_rules.append(updated)
else:
# Deleting the rule (skip adding it)
print(f" Deleting rule {r.id} that allows SSH from 0.0.0.0/0")
continue
# Keep non-offending rules as-is
updated = UpdateSecurityRuleDetails(
id=r.id,
direction=r.direction,
protocol=r.protocol,
description=r.description,
is_stateless=r.is_stateless,
source_type=getattr(r, "source_type", None),
source=getattr(r, "source", None),
destination=getattr(r, "destination", None),
destination_type=getattr(r, "destination_type", None),
tcp_options=r.tcp_options,
udp_options=r.udp_options,
icmp_options=r.icmp_options
)
new_rules.append(updated)
# Push the updated rules back to the NSG (this replaces all existing rules)
update_details = UpdateNetworkSecurityGroupSecurityRulesDetails(
security_rules=new_rules
)
print(f" Applying update to NSG {nsg_name} ({nsg_id})...")
vcn_client.update_network_security_group_security_rules(
network_security_group_id=nsg_id,
update_network_security_group_security_rules_details=update_details
)
print(" Update complete.")
print("\nScan/remediation finished.")
if __name__ == "__main__":
main()
3. How to use this in a monitoring/remediation workflow
-
Monitoring-only mode
- Set
DRY_RUN = Trueto run this script on a schedule (e.g., via OCI Functions + Events or an external scheduler) as a monitor and just log/report policy violations.
- Set
-
Auto-remediation mode
- Set
DRY_RUN = False - Choose:
RESTRICT_INSTEAD_OF_DELETE = Falseto delete insecure rules, orRESTRICT_INSTEAD_OF_DELETE = Trueand setNEW_ALLOWED_CIDRto your admin or bastion IP range.
- Set
-
Integrate with Cloud Guard or other monitoring:
- Use Cloud Guard to generate findings for “NSG with public SSH”.
- Trigger this script via OCI Functions or an external automation pipeline when such findings appear.
If you tell me how you’re currently doing “OCI Networking Monitoring” (Cloud Guard, custom scripts, Functions, etc.), I can adapt this to plug directly into that workflow.
Using Terraform
resource "oci_core_network_security_group" "example" {
# Replace with your NSG details
compartment_id = VAR_COMPARTMENT_OCID # substitute your compartment OCID
vcn_id = VAR_VCN_OCID # substitute your VCN OCID
display_name = "example-nsg"
}
# Ingress SSH rule restricted to a non-public CIDR instead of 0.0.0.0/0
resource "oci_core_network_security_group_security_rule" "ssh_restricted" {
network_security_group_id = oci_core_network_security_group.example.id
description = "Restricted SSH access (no public 0.0.0.0/0)"
direction = "INGRESS"
protocol = "6" # TCP
source_type = "CIDR_BLOCK"
source = "ALLOWED_SSH_CIDR" # substitute your allowed CIDR, e.g. "10.0.0.0/16" or a jump host /32
tcp_options {
destination_port_range {
min = 22
max = 22
}
}
}
If you currently have an oci_core_network_security_group_security_rule for SSH with source = "0.0.0.0/0", change its source to a non-public CIDR (as above) or remove that rule resource entirely; changing the rule will force replacement of the security rule itself but not the NSG.
For verification, terraform plan should show the NSG security rule with source changing from "0.0.0.0/0" to your restricted CIDR, or the public SSH rule being destroyed with no replacement.