# Meraki Suck and Spit (sas for short) # ## Brief: # sas sucks in a wireshark capture and spits out firewall rules in a group policy # for a Cisco Meraki MX with a default deny rule. This makes it perfect for creating # firewall rules for IoT devices and then restricting those IoT devices in case they later # become compromised. # sas is aware of resources that are accessed via a DNS name that use dynamically # changing IP addresses. # sas is also able to read in an existing group policy and update any existing firewall # rules with anything found in the packet capture not currently contained in the rule set. # ## Installation: # You need Python 3.2 or better. This script was written using Python 3.7.3 and has not # need used on any lower version of Python. # You need to install these Python modules: # pip install meraki-sdk # pip install -U python-dotenv # You need to install Wireshark (more specifically, you need tshark that comes with Wireshark). # # sas uses dotenv to safely store your credentials. Create a file called .meraki.env in your home # directory. For Linux this is typically /home/username. For Windows this is typically # c:\users\. # Into .meraki.env put this line: # x_cisco_meraki_api_key= # If you don't have an API key yet then following the instructions on this page: # https://documentation.meraki.com/zGeneral_Administration/Other_Topics/The_Cisco_Meraki_Dashboard_API # # To save time typing in the same configuration parameters in again over and over you # can create a .env file in the same directory you are going to run the script from # with these optional parameters (use none, some or all of them as you feel like). # orgName= # netName= # groupPolicyName= # ## Method of operation: # Start with the IoT device you are going to create firewall rules for turned off. # This is very important. # This is because sas needs to see all of the DNS queries that the device is making. # If you don't turn the device off before beginning then it may cache the DNS queries. # # Next start your packet capture. You can do this from the Meraki dashboard. # Select your MX security appliance. Make sure you do the packet capture on the # "LAN" interface. This is important because sas needs to see the requests coming # from the device prior to NAT. Select to download the packet capture. # Make sure you set the duration to be long enough for the device to start up # and operate normally. This might take 5 or 10 minutes. A longer capture is # better than a capture too short. # Enter a filter that will only capture packets for the one IoT device. # I suggest using a "host" filter, such as "host 192.168.1.10". # Once you have started your packet capture power up your IoT device. # # Once the capture completes and downloads to your machine you need to convert it to # json using tshark. tshark is a command line tool. Use a command line like: # tshark -2 -T json -r packet_capture.pcap >packet_capture.json # # Now you are ready to use sas. You can use "sas -h" to get help. You are # probably going to use a command line like this: # sas.py -f packet_capture.json -o "Your Org Name" -n "Your network name" -gp "Your group policy name" # Note that if you put into your .env file the organisation, network or group policy names # you don't need to specify them on the command line. If you have them in # both places the command line takes precedence. # # Your finished. Either the existing group policy will be updated or a new # group policy has been created with the firewall rules. You can now go # and apply this to your device (a client in the Meraki dashboard). You # could also potentially apply it to a VLAN interface. # # History: # When: Who: What: # 12-Dec-2019 PID Completed orignal. # 13-Mar-2020 PID Added support for templates. # # import os import json import argparse # Load global and local Meraki settings such as x_cisco_meraki_api_key from dotenv import load_dotenv load_dotenv() load_dotenv(dotenv_path=os.path.join(os.path.expanduser("~"),".meraki.env")) # The Meraki SDK from meraki_sdk.meraki_sdk_client import MerakiSdkClient from meraki_sdk.exceptions.api_exception import APIException meraki = MerakiSdkClient(os.getenv("x_cisco_meraki_api_key")) if os.getenv("base_uri"): meraki.config.base_uri=os.getenv("base_uri") # This stores a mapping from IP address to DNS name dnsResolver={} # This stores the firewall rules firewallRules={} # This function attempts to load the existing firewall rules in the group policy (which may not exist) def loadFirewallRules(orgName,netName,groupPolicyName): orgId=None netId=None groupPolicyId=None # Search for the org orgs = meraki.organizations.get_organizations() for org in orgs: if org['name'] == orgName: orgId=org['id'] break; if orgId == None: print("Invalid organization name supplied: "+orgName) exit(-1) # Search for the network for net in meraki.networks.get_organization_networks({'organization_id':orgId}): if net['name'] == netName: netId=net['id'] break; # If no network, search for a template if netId == None: for net in meraki.config_templates.get_organization_config_templates(orgId): if net['name'] == netName: netId=net['id'] break; if netId == None: print("Invalid network name supplied: "+netName) exit(-1) # Search for the group policy for gp in meraki.group_policies.get_network_group_policies(netId): if gp['name'] == groupPolicyName: groupPolicyId=gp['groupPolicyId'] # Load firewall rules from here firewallAndTrafficShaping=gp['firewallAndTrafficShaping'] if firewallAndTrafficShaping['settings'] == 'custom': for rule in firewallAndTrafficShaping['l3FirewallRules']: # Skip the deny any any rule - we will add it back at the end if (rule["policy"]=="deny" and rule["protocol"]=="any" and rule['destCidr']=="Any"): continue firewallRules[(rule["policy"],rule["protocol"],rule['destCidr'],rule['destPort'])]=rule['comment'] break; if groupPolicyId == None: # Create a new group policy print("Group policy does not exist - creating a new group policy: "+groupPolicyName) meraki.group_policies.create_network_group_policy({'network_id':netId,'create_network_group_policy':{'name':groupPolicyName}}) # This function searches the pcap json for DNS repliess and fills out the global dnsResolver with # a mapping of IP addresses to original DNS name queried def extractDNS(pcap): for packet in pcap: if "dns" in packet['_source']['layers']: if "Answers" in packet['_source']['layers']["dns"]: dnsQryName="" # Extract out the original query for query in packet['_source']['layers']["dns"]["Queries"]: dnsQryName=packet['_source']['layers']["dns"]["Queries"][query]["dns.qry.name"] # Extract out every returned IP address downstream as a result of the query above for answer in packet['_source']['layers']["dns"]["Answers"]: if "dns.a" in packet['_source']['layers']["dns"]["Answers"][answer]: dnsResolver[packet['_source']['layers']["dns"]["Answers"][answer]["dns.a"]]=dnsQryName # This function scans the pcap building the rules to add def extractFirewallRules(pcap): for packet in pcap: # Only process IPv4 packets if "ip" not in packet['_source']['layers']: continue if not packet['_source']['layers']["ip"]["ip.version"] == "4": continue dst=port="" rule=[] if "tcp" in packet['_source']['layers']: # Only process the SYN+ACK reply so we know the remote server actually responded if not (packet['_source']['layers']["tcp"]["tcp.flags_tree"]["tcp.flags.syn"] == "1" and packet['_source']['layers']["tcp"]["tcp.flags_tree"]["tcp.flags.ack"] == "1"): continue # Because this is a reply we have to swap the source and destination dst=packet['_source']['layers']["ip"]["ip.src"] # See if we know the DNS name for this IP address if dst in dnsResolver: dst=dnsResolver[dst] else: dst=dst+"/32" # Add the rule if it does not exist already rule=("allow","tcp",dst,packet['_source']['layers']["tcp"]["tcp.srcport"]) if rule not in firewallRules: firewallRules[rule]="" elif "udp" in packet['_source']['layers']: srcPort=packet['_source']['layers']["udp"]["udp.srcport"] dstPort=packet['_source']['layers']["udp"]["udp.dstport"] # Special support for DNS on UDP/53 if srcPort == "53" and dstPort != "53": continue # Special support for NTP on UDP/123 if srcPort == "123" and dstPort != "123": continue # Special support for https on UDP/443 if srcPort == "443" and dstPort != "443": continue dst=packet['_source']['layers']["ip"]["ip.dst"] # Ignore broadcasts if dst == "255.255.255.255": continue # Ignore MDNS if dst == "224.0.0.251": continue # Ignore SSDP if dst == "239.255.255.250": continue # See if we know the DNS name for this IP address if dst in dnsResolver: dst=dnsResolver[dst] else: dst=dst+"/32" # Add the rule if it does not exist already rule=("allow","udp",dst,dstPort) if rule not in firewallRules: firewallRules[rule]="" elif "icmp" in packet['_source']['layers'] and packet['_source']['layers']["icmp"]["icmp.type"] == "0": # We only process ICMP replies so have to swap source and destination dst=packet['_source']['layers']["ip"]["ip.src"] # See if we know the DNS name for this IP address if dst in dnsResolver: dst=dnsResolver[dst] else: dst=dst+"/32" # Add the rule if it does not exist already rule=("allow","icmp",dst,"Any") if rule not in firewallRules: firewallRules[rule]="" # This function attempts to save the firewall rules to the group policy def saveFirewallRules(orgName,netName,groupPolicyName): orgId=None netId=None groupPolicyId=None # Search for the org orgs = meraki.organizations.get_organizations() for org in orgs: if org['name'] == orgName: orgId=org['id'] break; # Search for the network for net in meraki.networks.get_organization_networks({'organization_id':orgId}): if net['name'] == netName: netId=net['id'] break; # If no network, search for a template if netId == None: for net in meraki.config_templates.get_organization_config_templates(orgId): if net['name'] == netName: netId=net['id'] break; # Search for the group policy for gp in meraki.group_policies.get_network_group_policies(netId): if gp['name'] == groupPolicyName: groupPolicyId=gp['groupPolicyId'] l3FirewallRules=[] for rule in firewallRules: l3FirewallRules.append({'policy':rule[0],'protocol':rule[1],'destCidr':rule[2],'destPort':rule[3],'comment':firewallRules[rule]}) l3FirewallRules.append({'policy':'deny','protocol':'any','destCidr':'any','destPort':'any','comment':'Default Deny'}) gp['firewallAndTrafficShaping']['settings'] = 'custom' gp['firewallAndTrafficShaping']['l3FirewallRules']=l3FirewallRules meraki.group_policies.update_network_group_policy({'network_id':netId,'group_policy_id':groupPolicyId,'update_network_group_policy':gp}) break; # {'l3FirewallRules': [{'comment': '', 'destCidr': '8.8.8.8/32', 'destPort': 'Any', 'policy': 'deny', 'protocol': 'any'}], 'l7FirewallRules': [], 'settings': 'custom', 'trafficShapingRules': []} def main(): # Meraki parameters orgName=None netName=None grouPolicy=None text=""" sas.py (suck and spit) sucks in a packet capture converted to a json by tshark and spits out Meraki firewall rules (in a group policy) to allow only that kind of traffic. This allows you to profile an IoT device when it is operating normally and then only allows it to access what is "normal" for it. Then if the IoT device is ever compromised it wont be able to access anything other than what it should be able to access normally. A sample tshark command line to produce the json file is "tshark -2 -T json -r file.pcap >file.json". In your home diretory you should have a .meraki.env file containing x_cisco_meraki_api_key= """ parser = argparse.ArgumentParser(description = text) parser.add_argument("-f", "--file", help="json file product by tshark to process",required=True,type=argparse.FileType('r')) parser.add_argument("-o", "--orgName", help="Meraki org name") parser.add_argument("-n", "--netName", help="Meraki network name") parser.add_argument("-gp", "--groupPolicyName", help="Meraki group policy name") args=parser.parse_args() orgName=os.getenv("orgName") netName=os.getenv("netName") groupPolicyName=os.getenv("groupPolicyName") if args.orgName: orgName=args.orgName if args.netName: netName=args.netName if args.groupPolicyName: groupPolicyName=args.groupPolicyName if not os.getenv("x_cisco_meraki_api_key"): print("x_cisco_meraki_api_key must be defined in .meraki.env in your home directory or in .env in the current directory") exit(-1) if not orgName: print("orgName must be defined on the command line, in .meraki.env in your home directory or in .env in the current directory") exit(-1) if not netName: print("netName must be defined on the command line, in .meraki.env in your home directory or in .env in the current directory") exit(-1) if not groupPolicyName: print("groupPolicyName must be defined on the command line, in .meraki.env in your home directory or in .env in the current directory") exit(-1) print("Processing "+groupPolicyName+" in "+netName+" in " + orgName) print("Stage 1/5: Loading existing firewall rules (if any)") loadFirewallRules(orgName,netName,groupPolicyName) print("Stage 2/5: Loading packet capture file") pcap = json.load(args.file) print("Stage 3/5: Searching for DNS replies") extractDNS(pcap); print("Stage 4/5: Creating firewall rule base from packet capture") extractFirewallRules(pcap); print("Stage 5/5: Saving firewall rule base to group policy") saveFirewallRules(orgName,netName,groupPolicyName) main()