AzureIdentifyNSGExposureRule
Identifies the first inbound Allow rule in a specified NSG that matches network exposure criteria. Returns rule details and a list of available priority numbers for inserting higher-priority rules.
python · Cloud Security Policy Management
Source
import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 import traceback import ipaddress import json def get_nsg_rules(subscription_id: str, rg_name: str, nsg_name: str, integration_instance: str) -> tuple[list, str]: """ Runs command 'azure-nsg-security-rules-list' to fetch all rules in a given Azure NSG. Args: subscription_id (str): Azure Subscription ID rg_name (str): The name of the Azure Resource Group where the NSG is located nsg_name (str): The name of the NSG to fetch rules from integration_instance (str): The name of the Azure integration instance to use for the command Returns: tuple[list, str]: - List of retrieved NSG Inbound rules, sorted in ascending priority order - String with name of the Integration instance that successfully retrieved NSG rules Raises: DemistoException: If there's an error fetching NSG rules """ cmd_args = { "subscription_id": subscription_id, "resource_group_name": rg_name, "network_security_group_name": nsg_name, "using": integration_instance, } result = demisto.executeCommand("azure-nsg-security-rules-list", cmd_args) nsg_rules = [] if result and len(result) > 1: # If multiple entries were returned, such as when multiple Azure integration instances are configured, # Identify the first entry with valid results. for entry in result: if not isError(entry): nsg_rules = [entry] break else: # If all entries are errors, use the first entry nsg_rules = [result[0]] else: nsg_rules = result if not nsg_rules: raise DemistoException( "Error retrieving security group details with command 'azure-nsg-security-rules-list'.\n" "Error: No results returned." ) if isError(nsg_rules): raise DemistoException( f"Error retrieving security group details with command 'azure-nsg-security-rules-list'.\n" f"Error: {json.dumps(nsg_rules[0].get('Contents', ''))}" ) instance_to_use = dict_safe_get(nsg_rules, (0, "Metadata", "instance")) # Get all Inbound rules inbound_rules = [ rule for rule in nsg_rules[0]["Contents"] if rule.get("properties", {}).get("direction", "").lower() == "inbound" ] # Sort inbound rules by priority (ascending order) sorted_rules = sorted(inbound_rules, key=lambda rule: rule.get("properties", {}).get("priority", 0)) if not sorted_rules: raise DemistoException("No inbound NSG rules found in the specified Network Security Group.") return sorted_rules, instance_to_use def find_matching_rule( port: int, protocol: str, destination_ips: list[ipaddress.IPv4Address | ipaddress.IPv6Address], nsg_rules: list[dict], ) -> tuple[str, int]: """ Find the first NSG Allow rule that matches the specified port, protocol, and any of the destination IPs. Args: port (int): The destination port to match protocol (str): The protocol to match (case-insensitive) destination_ips (list[IPv4Address | IPv6Address]): The destination IP addresses to match (rule matches if ANY IP matches) nsg_rules (list[dict]): List of NSG command results Returns: tuple[str, int]: (rule_name, rule_priority) Raises: DemistoException: If no matching NSG inbound rule is found """ # Normalize protocol for case-insensitive comparison protocol_normalized = protocol.lower() for rule in nsg_rules: properties = rule.get("properties", {}) # Skip rules that don't allow traffic if properties.get("access", "").lower() != "allow": continue # Check protocol match (case-insensitive) rule_protocol = properties.get("protocol", "").lower() if rule_protocol != "*" and rule_protocol != protocol_normalized: continue # Check port match if not _matches_port(port, properties): continue # Check if ANY of the destination IPs match if not _matches_destination_ip(destination_ips, properties): continue # Found a matching rule return rule.get("name", ""), properties.get("priority", 0) # No matching rule found raise DemistoException("No matching NSG inbound rule found for the specified IP/Port/Protocol.") def _matches_port(target_port: int, rule_properties: dict) -> bool: """ Check if the target port matches the rule's destination port configuration. Args: target_port (int): The port to check rule_properties (dict): The rule properties containing port information Returns: bool: True if the port matches, False otherwise """ # Check single port range field single_port_range = rule_properties.get("destinationPortRange", "") if single_port_range and _port_matches_range(target_port, single_port_range): return True # Check multiple port ranges field multiple_port_ranges = rule_properties.get("destinationPortRanges", []) return any(_port_matches_range(target_port, port_range) for port_range in multiple_port_ranges) def _port_matches_range(target_port: int, port_range: str) -> bool: """ Check if a port matches a port range specification. Args: target_port (int): The port to check port_range (str): Port range specification (e.g., "80", "80-90", "80,443,8080-8090", "*") Returns: bool: True if the port matches, False otherwise """ # Handle wildcard if port_range.strip() == "*": return True # Split by commas to handle multiple ports/ranges port_specs = [spec.strip() for spec in port_range.split(",")] for spec in port_specs: if "-" in spec: # Handle range (e.g., "8080-8090") try: start_port_str, end_port_str = spec.split("-", 1) start_port = int(start_port_str.strip()) end_port = int(end_port_str.strip()) if start_port <= target_port <= end_port: return True except (ValueError, IndexError): continue else: # Handle individual port try: if int(spec) == target_port: return True except ValueError: continue return False def _matches_destination_ip(target_ips: list[ipaddress.IPv4Address | ipaddress.IPv6Address], rule_properties: dict) -> bool: """ Check if any of the target IPs match the rule's destination address configuration. Args: target_ips (list[IPv4Address | IPv6Address]): The IP addresses to check (rule matches if ANY IP matches) rule_properties (dict): The rule properties containing address information Returns: bool: True if any IP matches, False otherwise """ # Check single destination address prefix single_prefix = rule_properties.get("destinationAddressPrefix", "") if single_prefix: for target_ip_obj in target_ips: if _ip_matches_prefix(target_ip_obj, single_prefix): return True # Check multiple destination address prefixes multiple_prefixes = rule_properties.get("destinationAddressPrefixes", []) for prefix in multiple_prefixes: for target_ip_obj in target_ips: if _ip_matches_prefix(target_ip_obj, prefix): return True return False def _ip_matches_prefix(target_ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address, address_prefix: str) -> bool: """ Check if an IP address matches an address prefix. Args: target_ip_obj: ipaddress.IPv4Address or ipaddress.IPv6Address object address_prefix (str): Address prefix (e.g., "10.0.0.0/24", "192.168.1.100", "*") Returns: bool: True if the IP matches, False otherwise """ if not address_prefix or address_prefix.strip() == "": return False address_prefix = address_prefix.strip() # Handle wildcard if address_prefix == "*": return True try: # Try to parse as network (CIDR notation) if "/" in address_prefix: network = ipaddress.ip_network(address_prefix, strict=False) return target_ip_obj in network else: # Try to parse as individual IP address prefix_ip = ipaddress.ip_address(address_prefix) return target_ip_obj == prefix_ip except ValueError: return False def find_available_priorities(target_rule_priority: int, nsg_rules: list, priority_count: int) -> list[int]: """ Identifies unused NSG rule priority values below the target priority that can be used to insert new rules above it. Args: target_rule_priority (int): The priority of the rule you want to find available priorities before. nsg_rules (list[dict]): List of NSG command results priority_count (int): Number of priorities needed Raises: DemistoException: if the requested number of available priorities are not found. Returns: list[int]: List of available priorities before the target rule priority """ rule_priorities = [] # Store all used priority values for rule in nsg_rules: rule_priorities.append(int(rule.get("properties", {}).get("priority", 0))) # Format values as a set for easier evaluation rule_priorities_set = set(rule_priorities) available_priorities = [] # Find available priorities counting down from target_rule_priority - 1 # Only goes down to 100, as this is the lowest value supported by Azure NSG rules for priority in range(target_rule_priority - 1, 99, -1): # Count down from target-1 to 100 if priority not in rule_priorities_set: available_priorities.append(priority) # Stop once we have found the requested number of priorities if len(available_priorities) >= priority_count: break # Raise error if requested priority count exceeds the number available if len(available_priorities) < priority_count: raise DemistoException( f"Requested {priority_count} available priority values, but only found {len(available_priorities)} " f"below the matching rule's priority of {target_rule_priority}." ) return available_priorities def process_nsg_info(args: dict[str, str]) -> CommandResults: """ Main command function to identify NSG rule causing an exposure. Args: args (Dict[str, Any]): Demisto.args() object Returns: CommandResults: Demisto CommandResults object containing: - MatchingRuleName: Name of the matching NSG rule - MatchingRulePriority: Priority of the matching rule - NextAvailablePriorityValues: List of available priorities - IntegrationInstance: Azure integration instance used Raises: ValueError: If required parameters are missing or invalid DemistoException: If Azure API operations fail """ subscription_id = args.get("subscription_id", "") rg_name = args.get("resource_group_name", "") nsg_name = args.get("network_security_group_name", "") destination_ip_input = args.get("private_ip_addresses", "") port = arg_to_number(args.get("port"), required=True) or 0 protocol = args.get("protocol", "") priority_count = arg_to_number(args.get("priority_count"), required=True) or 0 integration_instance = args.get("integration_instance", "") # Format provided IP addresses as a list. Handle both single IP and list of IPs destination_ips = [] if isinstance(destination_ip_input, list): destination_ips = destination_ip_input elif isinstance(destination_ip_input, str): # Handle comma-separated IPs in a single string or single IP destination_ips = [ip.strip() for ip in destination_ip_input.split(",") if ip.strip()] if not destination_ips: raise ValueError("At least one valid IP address must be provided in private_ip_address parameter") # Validate that all provided IPs are valid IP addresses and create IP Address objects valid_ips = [] for ip in destination_ips: try: valid_ips.append(ipaddress.ip_address(ip)) except ValueError: raise ValueError(f"Invalid IP address provided: {ip}") if not valid_ips: raise ValueError("No valid IP addresses found in private_ip_address parameter") # Retrieve NSG rules and identify the Azure integration instance to use nsg_rules, instance_to_use = get_nsg_rules(subscription_id, rg_name, nsg_name, integration_instance) # Find the name and priority value of the first Allow rule that matches the provided criteria matching_rule_name, priority = find_matching_rule(port, protocol, valid_ips, nsg_rules) # Identify available priority values to insert new rules ahead of the matched rule available_priorities = find_available_priorities(priority, nsg_rules, priority_count) outputs = { "MatchingRuleName": matching_rule_name, "MatchingRulePriority": priority, "NextAvailablePriorityValues": available_priorities, "IntegrationInstance": instance_to_use, } return CommandResults( outputs_prefix="AzurePublicExposure", outputs_key_field="MatchingRuleName", outputs=outputs, raw_response=outputs, ) def main(): try: return_results(process_nsg_info(demisto.args())) except Exception as ex: demisto.error(traceback.format_exc()) # print the traceback return_error(f"Failed to execute AzureIdentifyNSGExposureRule. Error: {str(ex)}") if __name__ in ("__main__", "__builtin__", "builtins"): # pragma: no cover main()
README
This script identifies the first inbound Allow rule in the specified NSG that matches the criteria for network exposure. It returns details about the identified rule as well as a list of available priority numbers to insert new security rules with a higher priority.
Script Data
| Name | Description |
|---|---|
| Script Type | python3 |
| Cortex XSOAR Version | 6.10.0 |
Inputs
| Argument Name | Description |
|---|---|
| subscription_id | The Azure Subscription ID where the NSG resides. |
| resource_group_name | The Azure Resource Group Name where the NSG resides. |
| network_security_group_name | The Azure Network Security Group (NSG) Name to analyze for exposure rules. |
| private_ip_addresses | The destination private IP address(es) of the Virtual Machine interface. |
| port | TCP/UDP port to be restricted. |
| protocol | Protocol of the port to be restricted. |
| priority_count | The number of available priority values below the matching rule to return. |
| integration_instance | The Azure Integration Instance to use. |
Outputs
| Path | Description | Type |
|---|---|---|
| AzurePublicExposure.MatchingRuleName | The name of the matching inbound security rule in the NSG that allows exposure. | String |
| AzurePublicExposure.MatchingRulePriority | The priority number of the matching inbound security rule. | Number |
| AzurePublicExposure.NextAvailablePriorityValues | The next available priority values to insert new security rules before the matching rule, given in descending order. | Unknown |
| AzurePublicExposure.IntegrationInstance | The Azure Integration Instance used for identification. | String |