├── splunkConfig.txt ├── examples2 ├── CVEs2.csv ├── reachability2.csv ├── vulnerabilities2.csv ├── cveToEvent2.csv └── eventSet2.csv ├── scripts ├── CVEs.csv └── web_scraping.py ├── examples ├── CVEs.csv ├── vulnerabilities.csv ├── reachability.csv ├── cveToEvent.csv └── eventSet.csv ├── state_node.py ├── event_finder.py ├── main.py ├── possibilities.py ├── vulnerability_node.py ├── graph_traverser.py ├── graph_generator.py ├── DeveloperGuide.md ├── input_parser.py └── README.md /splunkConfig.txt: -------------------------------------------------------------------------------- 1 | username 2 | password -------------------------------------------------------------------------------- /examples2/CVEs2.csv: -------------------------------------------------------------------------------- 1 | CVE-2016-1393 2 | CVE-2016-3169 3 | CVE-2018-1973 4 | CVE-2018-9105 5 | CVE-2019-12491 6 | -------------------------------------------------------------------------------- /examples2/reachability2.csv: -------------------------------------------------------------------------------- 1 | A,"A,1521","A,80","B,53","C,1521","D,53", 2 | B,"B,53","C,1521", 3 | C,"A,80","C,1521", 4 | D,"D,53" 5 | -------------------------------------------------------------------------------- /scripts/CVEs.csv: -------------------------------------------------------------------------------- 1 | CVE-2016-3169 2 | CVE-2013-1534 3 | CVE-2002-0392 4 | CVE-2016-1393 5 | CVE-2018-1386 6 | CVE-2016-1301 7 | CVE-2018-9105 -------------------------------------------------------------------------------- /examples/CVEs.csv: -------------------------------------------------------------------------------- 1 | CVE-2002-0392 2 | CVE-2013-1534 3 | CVE-2016-1301 4 | CVE-2016-1393 5 | CVE-2016-3169 6 | CVE-2018-1386 7 | CVE-2018-9105 8 | -------------------------------------------------------------------------------- /examples2/vulnerabilities2.csv: -------------------------------------------------------------------------------- 1 | HOST,CVE_CATALOG_ID,SERVICE_PORTS 2 | A,CVE-2016-3169,1521/TCP 3 | A,CVE-2018-1973,80/TCP 4 | B,CVE-2016-1393,53/TCP 5 | C,CVE-2018-9105,1521/TCP 6 | D,CVE-2019-12491,53/TCP 7 | -------------------------------------------------------------------------------- /examples/vulnerabilities.csv: -------------------------------------------------------------------------------- 1 | HOST,CVE_CATALOG_ID,SERVICE_PORTS 2 | A,CVE-2016-3169,1521/TCP 3 | B,CVE-2013-1534,1521/TCP 4 | C,CVE-2002-0392,80/TCP 5 | D,CVE-2016-1393,53/TCP 6 | D,CVE-2018-1386,1521/TCPs 7 | E,CVE-2016-1301,80/TCP 8 | F,CVE-2018-9105,1521/TCP 9 | -------------------------------------------------------------------------------- /examples/reachability.csv: -------------------------------------------------------------------------------- 1 | A,"A,1521","B,1521","C,80","D,53","D,80" 2 | B,"A,1521","B,1521","C,80",, 3 | C,"A,1521","B,1521","C,80","E,80", 4 | D,"D,53","D,80","D,1521","E,80","F,1521" 5 | E,"D,53","D,80","D,1521","E,80","F,1521" 6 | F,"D,53","D,80","D,1521","E,80","F,1521" -------------------------------------------------------------------------------- /examples2/cveToEvent2.csv: -------------------------------------------------------------------------------- 1 | CVE,DESCRIPTION 2 | CVE-2016-1393,SQL query submitted via url 3 | CVE-2016-3169,user_save function called with an explicit category 4 | CVE-2018-1973,suspected privilege escalation through IBM API 5 | CVE-2018-9105,XPC message sent to make a new OpenVPN connection 6 | CVE-2019-12491,suspected privilege escalation from OnApp 7 | -------------------------------------------------------------------------------- /examples/cveToEvent.csv: -------------------------------------------------------------------------------- 1 | CVE,DESCRIPTION 2 | CVE-2016-3169,user_save function called with an explicit category 3 | CVE-2013-1534,remote user executes arbitrary code via unknown vector 4 | CVE-2002-0392,chunk-encoded HTTP request received 5 | CVE-2016-1393,SQL query submitted via url 6 | CVE-2018-1386,local user executes with root privilege 7 | CVE-2016-1301,remote user changes password via HTTP request 8 | CVE-2018-9105,XPC message sent to make a new OpenVPN connection -------------------------------------------------------------------------------- /state_node.py: -------------------------------------------------------------------------------- 1 | class StateNode(object): 2 | def __init__(self, hostname, accessLevel): 3 | self.hostname = hostname 4 | self.accessLevel = accessLevel 5 | self.type = 'state' 6 | 7 | def to_string(self): 8 | return "({}, {}, {})".format(self.hostname, self.accessLevel, self.type) 9 | 10 | def __eq__(self, other): 11 | return self.hostname == other.hostname and self.accessLevel == other.accessLevel 12 | 13 | def __hash__(self): 14 | return hash(('hostname', self.hostname, 'accessLevel', self.accessLevel)) 15 | -------------------------------------------------------------------------------- /examples/eventSet.csv: -------------------------------------------------------------------------------- 1 | TIMESTAMP,SRCHOST,DSTHOST,SRCIP,DSTIP,SRCPORT,DSTPORT,DESCRIPTION 2 | 1563861738,U,C,102.68.0.2,192.170.5.11,1234,80,chunk-encoded HTTP request received 3 | 1563861790,C,A,192.170.5.11,192.170.5.6,5555,1521,user_save function called with an explicit category 4 | 1563861805,A,D,192.170.5.6,192.170.27.24,4342,53,SQL query submitted via url 5 | 1563861823,D,D,192.170.27.24,192.170.27.24,2341,1521,local user executes with root privilege 6 | 1563861880,D,F,192.170.27.24,192.170.27.22,7777,1521,XPC message sent to make a new OpenVPN connection 7 | 1563861901,F,U,192.170.27.22,102.68.0.2,7654,1521,suspected data exfiltration -------------------------------------------------------------------------------- /examples2/eventSet2.csv: -------------------------------------------------------------------------------- 1 | TIMESTAMP,SRCHOST,DSTHOST,SRCIP,DSTIP,SRCPORT,DSTPORT,DESCRIPTION 2 | 1563861790,U,A,102.68.0.2,192.170.5.6,5555,1521,user_save function called with an explicit category 3 | 1563861805,A,B,192.170.5.6,192.170.27.24,1521,53,SQL query submitted via url 4 | 1563861820,A,C,192.170.5.6,192.170.27.22,1521,1521,XPC message sent to make a new OpenVPN connection 5 | 1563861830,B,C,192.170.27.24,192.170.27.22,53,1521,XPC message sent to make a new OpenVPN connection 6 | 1563861835,C,A,192.170.27.22,192.170.5.6,1521,80,suspected privilege escalation through IBM API 7 | 1563861850,A,D,192.170.5.6,192.170.5.11,80,53,suspected privilege escalation from OnApp 8 | -------------------------------------------------------------------------------- /event_finder.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import subprocess 4 | 5 | class EventFinder(object): 6 | def __init__(self, username, password): 7 | self.splunk_username = username 8 | self.splunk_password = password 9 | 10 | # Checks if a vulnerability event is present in the event set 11 | def containsVulnEvent(self, description, host, port, timestamp): 12 | search_str = 'python search.py "search ' 13 | query = search_str + description + " SRCHOST=*" + " DSTHOST=" + host + " DSTPORT=" + str(port) + " TIMESTAMP<" + str(timestamp) 14 | query += '" --username="' + self.splunk_username + '" --password="' + self.splunk_password + '" --output_mode=json' 15 | # print(query) 16 | os.chdir("splunk/examples") 17 | status, result = subprocess.getstatusoutput(query) 18 | json_result = json.loads(result)["results"] 19 | os.chdir("../..") 20 | if json_result == []: 21 | return None 22 | return json_result 23 | 24 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from event_finder import EventFinder 2 | from graph_generator import GraphGenerator 3 | from graph_traverser import GraphTraverser 4 | from input_parser import Parser 5 | from possibilities import Possibilities 6 | from state_node import StateNode 7 | 8 | parser = Parser() 9 | startNodeSet = parser.parseStartNodes() 10 | adjList = parser.parseReachability() 11 | vulnDict, portDict = parser.parseVulnerabilities() 12 | eventMapping = parser.parseEventMapping() 13 | eventSet = EventFinder() 14 | 15 | # Generate attack graph 16 | graphGenerator = GraphGenerator(startNodeSet, adjList, vulnDict, portDict) 17 | DG = graphGenerator.generate_graph() 18 | 19 | timestamp, src, dst, port, description, accessLevel = parser.parseNotableEvent() 20 | 21 | graphTraverser = GraphTraverser(DG, eventSet, eventMapping, portDict.keys()) 22 | eventSequence = graphTraverser.start_traversal(timestamp, src, dst, port, description, accessLevel) 23 | 24 | # Print possibilities 25 | crownJewelSet = parser.parseCrownJewels() 26 | possibilitiesGenerator = Possibilities() 27 | notableEventStateNode = StateNode(dst, accessLevel) 28 | possibilitiesGenerator.printPossiblePaths(DG, notableEventStateNode, crownJewelSet) 29 | -------------------------------------------------------------------------------- /possibilities.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | from state_node import StateNode 3 | from vulnerability_node import VulnerabilityNode 4 | 5 | class Possibilities(object): 6 | 7 | def printPossiblePaths(self, DG, src, crownJewelSet): 8 | for dest in crownJewelSet: 9 | if DG.has_node(dest): 10 | paths = nx.all_simple_paths(DG, src, dest) 11 | if sum(1 for x in paths) == 0: 12 | print("There are no possible paths from notable event node (" + src.hostname + ", " + str(src.accessLevel) 13 | + ") to crown jewel (" + dest.hostname + ", " + str(dest.accessLevel) + ")") 14 | return 15 | 16 | print("POSSIBLE PATHS TO REACH CROWN JEWEL " + dest.hostname + ":") 17 | paths = nx.all_simple_paths(DG, src, dest) 18 | pathCounter = 0 19 | for path in paths: 20 | pathCounter = pathCounter + 1 21 | print("Possible path " + str(pathCounter) + ":") 22 | stepsCounter = 1 23 | for node in path: 24 | if type(node) is StateNode: 25 | if node.hostname == src.hostname and node.accessLevel == src.accessLevel: 26 | print(str(stepsCounter) + ") notable event at (" + node.hostname + ", " + str(node.accessLevel) + ")") 27 | stepsCounter = stepsCounter + 1 28 | else: 29 | print("on node " + "(" + node.hostname + ", " + str(node.accessLevel) + ")") 30 | elif type(node) is VulnerabilityNode: 31 | print(str(stepsCounter) + ") exploit " + node.vulnerabilityName + " on port " + str(node.vulnerabilityPort), end = ' ') 32 | stepsCounter = stepsCounter + 1 33 | 34 | -------------------------------------------------------------------------------- /vulnerability_node.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | from pymongo import MongoClient 3 | 4 | class VulnerabilityNode(object): 5 | def __init__(self, CVEName, port, entry=False): 6 | self.vulnerabilityName = CVEName 7 | self.vulnerabilityPort = port 8 | self.mapping1 = { 'High': 2, 'Low': 1, 'None': 0 } 9 | self.mapping2 = { 'Admin': 2, 'User': 1, 'None': 0 } 10 | self.type = 'vuln' 11 | vulns = MongoClient('localhost', 27017).project.vulnerabilities 12 | query = {} 13 | query['cveName'] = CVEName 14 | result = list(vulns.find(query))[0] 15 | self.accessVector = result['access_vector'] 16 | self.accessLevel = result['gained_access'] 17 | self.requiredPrivilege = result['required_priv'] 18 | self.entry = entry 19 | 20 | # def get_gained_access_and_access_vector(self): 21 | # url = "https://www.cvedetails.com/cve/" + self.vulnerabilityName 22 | # req = Request(url, headers={ 'User-Agent': 'Mozilla/5.0' }) 23 | # html_doc = urlopen(req).read() 24 | # soup = BeautifulSoup(html_doc, 'lxml') 25 | # table = soup.find("table", { 'id': 'cvssscorestable', 'class': 'details' }) 26 | # field_row = table.findAll("tr")[6] 27 | # field_value = field_row.find("span").string 28 | # return self.mapping2[field_value] 29 | 30 | # def get_required_privilege(self): 31 | # url = "https://nvd.nist.gov/vuln/detail/" + self.vulnerabilityName 32 | # html_doc = urlopen(url) 33 | # soup = BeautifulSoup(html_doc, 'lxml') 34 | # tag = soup.find('span', { 'data-testid': 'vuln-cvssv3-pr' }) 35 | # if tag: 36 | # field_value = tag.string.strip() 37 | # else: 38 | # field_value = "None" # By default, "None" privileges are required 39 | # return self.mapping1[field_value] 40 | 41 | def to_string(self): 42 | return "({}, {}, {}, {}, {})".format(self.vulnerabilityName, self.vulnerabilityPort, self.requiredPrivilege, self.accessLevel, self.type) 43 | 44 | def __eq__(self, other): 45 | return self.vulnerabilityName == other.vulnerabilityName and self.vulnerabilityPort == other.vulnerabilityPort 46 | 47 | def __hash__(self): 48 | return hash(('name', self.vulnerabilityName, 'port', self.vulnerabilityPort)) 49 | 50 | # v = VulnerabilityNode('CVE-2012-3137', 53) 51 | # print(v.accessLevel) 52 | # print(v.accessVector) 53 | # print(v.requiredPrivilege) -------------------------------------------------------------------------------- /scripts/web_scraping.py: -------------------------------------------------------------------------------- 1 | # Script to web scrape vulnerability information into mongodb 2 | 3 | from bs4 import BeautifulSoup 4 | from pymongo import MongoClient 5 | from urllib.request import Request, urlopen 6 | import csv 7 | import os 8 | 9 | client = MongoClient('localhost', 27017) 10 | db = client.project 11 | vulns = db.vulnerabilities 12 | vulns.drop() 13 | vulns = db.vulnerabilities 14 | 15 | mapping1 = { 'High': 2, 'Low': 1, 'None': 0 } 16 | mapping2 = { 'Admin': 2, 'User': 1, 'None': 0 } 17 | 18 | file_input = input("Enter CSV file (including extension) to read CVEs from: ") 19 | filename = file_input.split("/")[-1] 20 | directory = file_input.replace(filename, '') 21 | 22 | if directory: 23 | curr_dir = os.getcwd() 24 | os.chdir(directory) 25 | print(os.getcwd()) 26 | 27 | try: 28 | with open(filename) as csv_file: 29 | # os.chdir(curr_dir) 30 | csv_reader = csv.reader(csv_file) 31 | for row in csv_reader: 32 | CVE = row[0] 33 | url = "https://www.cvedetails.com/cve/" + CVE 34 | req = Request(url, headers={ 'User-Agent': 'Mozilla/5.0' }) 35 | html_doc = urlopen(req).read() 36 | soup = BeautifulSoup(html_doc, 'lxml') 37 | table = soup.find("table", { 'id': 'cvssscorestable', 'class': 'details' }) 38 | field_row = table.findAll("tr")[6] 39 | field_value = field_row.find("span").string 40 | gained_access = mapping2[field_value] 41 | 42 | url = "https://nvd.nist.gov/vuln/detail/" + CVE 43 | html_doc = urlopen(url) 44 | soup = BeautifulSoup(html_doc, 'lxml') 45 | tag = soup.find('span', { 'data-testid': 'vuln-cvssv3-pr' }) 46 | if tag: 47 | field_value = tag.string.strip() 48 | else: 49 | field_value = "None" # By default, "None" privileges are required 50 | required_priv = mapping1[field_value] 51 | 52 | tag = soup.find('span', { 'data-testid': 'vuln-cvssv2-av' }) 53 | attack_vector = tag.string.strip() 54 | 55 | # Add entry 56 | document = {} 57 | document['cveName'] = CVE 58 | document['gained_access'] = gained_access 59 | document['required_priv'] = required_priv 60 | document['access_vector'] = attack_vector 61 | vulns.insert_one(document) 62 | 63 | print("Successfully imported CVE details") 64 | 65 | except IOError: 66 | print("File {} does not exist".format(filename)) 67 | exit() 68 | -------------------------------------------------------------------------------- /graph_traverser.py: -------------------------------------------------------------------------------- 1 | class GraphTraverser(object): 2 | def __init__(self, graph, eventSet, eventMapping, networkNodes): 3 | self.graph = graph 4 | self.eventSet = eventSet 5 | self.eventMapping = eventMapping 6 | self.networkNodes = networkNodes 7 | 8 | def dfs(self, v, reverseList, timestamp, dst, port, src=None): 9 | # print("dfs called") 10 | # print(v.to_string()) 11 | # for i in self.graph.predecessors(v): 12 | # print(i.to_string()) 13 | 14 | if v.type == 'vuln' and v.entry and src not in self.networkNodes: 15 | # reverseList.reverse() 16 | # print("Printing at node {}".format(v.to_string())) 17 | print('') 18 | return self.print_path(reverseList[::-1]) 19 | 20 | for i in self.graph.predecessors(v): 21 | # print("Predecessor: {}".format(i.to_string())) 22 | if i.type == 'vuln': 23 | description = self.eventMapping[i.vulnerabilityName] 24 | eventList = self.eventSet.containsVulnEvent(description, dst, i.vulnerabilityPort, timestamp) 25 | if eventList: 26 | for event in eventList: 27 | event_string = event['TIMESTAMP'] + ', ' + event['SRCHOST'] + ', ' + event['DSTHOST'] + ', ' + description 28 | # print("Adding event: {}".format(event_string)) 29 | reverseList.append(event_string) 30 | self.dfs(i, reverseList, event['TIMESTAMP'], event['DSTHOST'], event['DSTPORT'], event['SRCHOST']) 31 | reverseList.pop() 32 | # print("Returned from state node") 33 | 34 | elif i.type == 'state': 35 | self.dfs(i, reverseList, timestamp, src, port) 36 | # print("Returned from vuln node") 37 | 38 | def start_traversal(self, timestamp, src, dst, port, description, accessLevel): 39 | reverseList = [] 40 | reverseList.append('Notable event: ' + str(timestamp) + ', ' + src + ', '+ dst + ', ' + description) 41 | notableEventNode = self.find_node(src, accessLevel) 42 | if notableEventNode: 43 | eventSequence = self.dfs(notableEventNode, reverseList, timestamp, src, port) 44 | else: 45 | print("The attacker cannot have access level {} at host {}".format(accessLevel, src)) 46 | 47 | def find_node(self, dst, accessLevel): 48 | for i in self.graph.nodes: 49 | if i.type == 'state' and i.hostname == dst and i.accessLevel == accessLevel: 50 | return i 51 | 52 | def print_path(self, list): 53 | print("Entry: {}".format(list[0])) 54 | for i in list[1:]: 55 | print(' -> ' + i) -------------------------------------------------------------------------------- /graph_generator.py: -------------------------------------------------------------------------------- 1 | # Generates an attack graph of state nodes and vulnerability nodes 2 | 3 | import networkx as nx 4 | from input_parser import Parser 5 | from state_node import StateNode 6 | 7 | class GraphGenerator(object): 8 | 9 | def __init__(self, startNodeSet, adjList, vulnDict, portDict): 10 | self.startNodeSet = startNodeSet 11 | self.adjList = adjList 12 | self.vulnDict = vulnDict 13 | self.portDict = portDict 14 | 15 | # needs to link with network topology 16 | def get_reachable(self, hostname): 17 | reachableSet = self.adjList[hostname] 18 | return reachableSet 19 | 20 | def get_vulnerabilities(self, host, port): 21 | if (host, port) not in self.vulnDict: 22 | return None 23 | return self.vulnDict[(host, port)] 24 | 25 | def get_access_granted(self, vulnerabilityNode, currAccessLevel): 26 | if vulnerabilityNode.accessVector == 'Network': 27 | return vulnerabilityNode.accessLevel 28 | elif vulnerabilityNode.accessVector == 'Local': 29 | if currAccessLevel < vulnerabilityNode.accessLevel: 30 | return vulnerabilityNode.accessLevel 31 | else: 32 | return currAccessLevel 33 | 34 | def generate_graph(self): 35 | DG = nx.DiGraph() 36 | 37 | # add vulnerabilities for start nodes 38 | for startNode in self.startNodeSet: 39 | startNodePorts = self.portDict[startNode.hostname] 40 | for port in startNodePorts: 41 | vulnerabilitySet = self.get_vulnerabilities(startNode.hostname, port) 42 | if not vulnerabilitySet: 43 | continue 44 | for vulnerabilityNode in vulnerabilitySet: 45 | vulnerabilityNode.entry = True 46 | if vulnerabilityNode.requiredPrivilege == 0: 47 | startNode.accessLevel = vulnerabilityNode.accessLevel 48 | DG.add_edge(vulnerabilityNode, startNode) 49 | # print("Added edge from {} to {}".format(vulnerabilityNode.to_string(), startNode.to_string())) 50 | 51 | stateNodeSet = self.startNodeSet 52 | newStateNodes = set() 53 | 54 | while stateNodeSet: 55 | # iterate through each state node's reachable node set 56 | for index, stateNode in enumerate(stateNodeSet): 57 | 58 | # print("State node: {}".format(stateNode.to_string())) 59 | 60 | host = stateNode.hostname 61 | currAccessLevel = stateNode.accessLevel 62 | reachableSet = self.get_reachable(host) 63 | 64 | # reachable is a tuple (hostname, port) 65 | for reachable in reachableSet: 66 | # print("Host {} is reachable to host {}, port {}".format(host, reachable[0], reachable[1])) 67 | vulnerablitySet = self.get_vulnerabilities(reachable[0], reachable[1]) 68 | 69 | if not vulnerablitySet: # No vulnerabilities associated 70 | continue 71 | 72 | # add each vulnerability node as the state node's child node if: 73 | # 1) sufficient privilege level 74 | # 2) reachable to port associated with that vulnerability 75 | for vulnerabilityNode in vulnerablitySet: 76 | # print("Reachable node {} has vulnerability {}".format(reachable, vulnerabilityNode.to_string())) 77 | if (currAccessLevel >= vulnerabilityNode.requiredPrivilege) and not (vulnerabilityNode.accessVector == 'Local' and not host == reachable[0]): 78 | if not DG.has_edge(vulnerabilityNode, stateNode) and not DG.has_edge(stateNode, vulnerabilityNode): 79 | # print("No edge from {} to {}".format(vulnerabilityNode.to_string(), stateNode.to_string())) 80 | DG.add_edge(stateNode, vulnerabilityNode) 81 | # print("Added edge from {} to {}".format(stateNode.to_string(), vulnerabilityNode.to_string())) 82 | 83 | newAccessLevel = self.get_access_granted(vulnerabilityNode, currAccessLevel) 84 | vulnerableNode = StateNode(reachable[0], newAccessLevel) 85 | if not DG.has_node(vulnerableNode): 86 | newStateNodes.add(vulnerableNode) 87 | # print("Adding {} to newStateNodes".format(vulnerableNode.to_string())) 88 | if not DG.has_edge(vulnerabilityNode, vulnerableNode): 89 | DG.add_edge(vulnerabilityNode, vulnerableNode) 90 | # print("Added edge from {} to {}".format(vulnerabilityNode.to_string(), vulnerableNode.to_string())) 91 | 92 | if index == len(stateNodeSet) - 1: 93 | stateNodeSet = newStateNodes 94 | newStateNodes = set() 95 | 96 | # pos = nx.spring_layout(DG) 97 | # nx.draw_networkx_nodes(DG, pos) 98 | # nx.draw_networkx_edges(DG, pos) 99 | # plt.show() 100 | return DG 101 | -------------------------------------------------------------------------------- /DeveloperGuide.md: -------------------------------------------------------------------------------- 1 | # Developer Guide 2 | This page documents the solution architecture and algorithms used in the project. The project solution is inspired by an [academic paper](http://people.cs.ksu.edu/~halmohri/files/Practical%20Attack%20Graph%20Generation%20for%20Network%20Defense.pdf) written by K. Ingols, R. Lippmann and K. Piwowarski. 3 | 4 | ## Solution Architecture 5 | 6 | ### Attack Graph Generation 7 | An attack graph (as referred to in this project) comprises of: 8 | * State node: A host in the network + the access level an attacker has on that host 9 | * Vulnerability node: A known CVE 10 | * Edge from a Vulnerability node to a State node if: 11 | * The host of the state node has that known CVE 12 | * Exploiting that vulnerability results in the attacker having the access level of the state node on that host 13 | * Edge from a State node to Vulnerability node if: 14 | * The host of the state node can reach the host and port containing the vulnerability 15 | * The attacker's current access level is sufficient to exploit the vulnerability 16 | 17 | The program takes on a breadth-first approach to generate an attack graph, beginning with the vulnerabilities associated with the entry hosts as input by the user. 18 | 19 | ### Event Sequence Reconstruction 20 | To chain up relevant events together, the program goes through the event logs (hosted within Splunk) in reverse chronological order from the notable event while doing a backwards depth-first traversal on the attack graph starting from where a notable event has occurred concurrently. At each node during the traversal, the event logs dictate the path the algorithm takes: 21 | * Traverse from a State node to a preceding Vulnerability node if an exploitation event for that vulnerability has occurred 22 | * Traverse from a Vulnerability node to a preceding State node if the vulnerability event occurred when the attacker was initially at the preceding state node's host (by checking the source of the event) 23 | 24 | An event sequence / attack path is complete when an event originating from an external IP address to an entry host has been identified. 25 | 26 | ### Listing of Subsequent Attack Paths 27 | The program generates all possible simple paths from the state note of the notable event to all crown jewels, as specified by the user. The hosts passed through, ports used, access levels gained and vulnerabilities exploited for each path are printed. 28 | 29 | ## Assumptions 30 | * All vulnerabilities that can or will be exploited are known 31 | * Log entries are tagged with a description of the event that occurred 32 | * There is a one-to-one mapping of a CVE ID to an event description 33 | * Firewalls cannot be compromised and their rules will always be followed 34 | * At the point of the notable event, the attacker's access level is known 35 | * An IP address belonging to a host in the network can be accurately resolved to its hostname 36 | * The event set is finite and can be searched through in a feasible amount of time 37 | 38 | ## Future Work 39 | 40 | #### Interfacing with Skybox Security (or other network assurance tools) 41 | The current solution reads in CSV files containing information about connectivity between network hosts and vulnerability occurrences within the network. To make the solution more scalable, integration with the Skybox tool will be required. The Skybox tool will be able to automatically compute the reachability of hosts and returns the vulnerabilities associated with each node. 42 | 43 | #### Detection of vulnerability exploitation from events 44 | Currently, the user provides a rigid one-to-one mapping of CVE to event description. However, realistically, the act of exploiting a vulnerability can manifest itself as several events and the event descriptions might not be so predictable. Improvements can be made to use regex expressions to better identify CVE exploitations from events. 45 | 46 | #### Dealing with multiple event results 47 | One assumption made is that each successful event search returns exactly one result. However, in reality, there may be many events matching the given query. A more refined algorithm will be required to iterate through all event results, or select the most likely event to be included. 48 | 49 | #### Accounting for trust relationships 50 | Access from one host to another may be permitted by trust relationships or means of authentication eg remote logins. An attacker is able to gain access to a connected machine without exploiting a vulnerability on that machine. 51 | 52 | #### A GUI that can display the attack paths on a network topology 53 | A GUI can be implemented to improve readability of attack paths, especially on a scaled-up version of a network. 54 | 55 | #### More discrete user access levels 56 | `none` and `root` are already rather unique. However, `user` is not always homogeneous. As there are different users, they would have specific access to different programs which blocks other users. As such, we can define different types of `user` access in a Node as 1.x e.g. 1.1, 1.2, 1.3 (recall that the prefix of 1 is taken by our program to be the general form of `user`). This would paint a more specific attack path. 57 | 58 | #### Prediction capabilities 59 | Currently, to pre-emptively prevent the attacker from reaching the crown jewels, all possible paths leading to them are listed by the program. To aid the defender in planning a more focused course of action, the program can predict which paths are more likely to be taken by the attacker. This can be performed in many ways, such as: 60 | * tagging events to a phase in the cyber kill chain 61 | * analysis of the attacker's intention and capabilities 62 | * analysis of the level of difficulty of taking each possible path 63 | 64 | Machine learning can make use of attacks that have happened in the past to achieve the above methods. 65 | -------------------------------------------------------------------------------- /input_parser.py: -------------------------------------------------------------------------------- 1 | from state_node import StateNode 2 | from vulnerability_node import VulnerabilityNode 3 | import csv 4 | import os 5 | 6 | class Parser(object): 7 | 8 | def parseStartNodes(self): 9 | while True: 10 | try: 11 | numStartNodes = int(input("Enter number of start nodes in attack graph: >>>")) 12 | if numStartNodes > 0: 13 | break 14 | print("Please enter a positive integer") 15 | except ValueError: 16 | print("Please enter a positive integer") 17 | 18 | while True: 19 | names = input("Enter start node(s) name(s), separated by comma >>>") 20 | 21 | if names: 22 | startNodesNames = names.split(',') 23 | if len(startNodesNames) == numStartNodes: 24 | break 25 | else: 26 | print("Number of start node(s) must be as specified") 27 | continue 28 | print("Please enter a non-empty start node name") 29 | 30 | startNodeSet = set() 31 | for i in range(0, numStartNodes): 32 | stateNode = StateNode(startNodesNames[i], 0) 33 | startNodeSet.add(stateNode) 34 | 35 | # Reject framework if start set is empty 36 | if not startNodeSet: 37 | print("Attack graph cannot have no start nodes") 38 | exit() 39 | 40 | return startNodeSet 41 | 42 | def parseNotableEvent(self): 43 | while True: 44 | print("Enter notable event >>>") 45 | event = input() 46 | if event: 47 | eventComponents = event.split(",") 48 | if len(eventComponents) == 8: 49 | break 50 | else: 51 | print("Please enter a valid notable event") 52 | continue 53 | print("Please enter a non-empty notable event") 54 | 55 | while True: 56 | try: 57 | accessLevel = int(input("Enter access level of attacker at the notable event>>>")) 58 | if accessLevel >= 0 and accessLevel <= 2: 59 | break 60 | print("Please enter 0 (no access), 1 (user) or 2 (root)") 61 | except ValueError: 62 | print("Please enter 0 (no access), 1 (user) or 2 (root)") 63 | 64 | timestamp = int(eventComponents[0]) 65 | src = eventComponents[1] 66 | dst = eventComponents[2] 67 | port = int(eventComponents[6]) 68 | description = eventComponents[7] 69 | 70 | return timestamp, src, dst, port, description, accessLevel 71 | 72 | def parseReachability(self): 73 | file_input = input("Enter CSV file (including extension) containing reachability graph: ") 74 | filename = file_input.split("/")[-1] 75 | directory = file_input.replace(filename, '') 76 | 77 | if directory: 78 | curr_dir = os.getcwd() 79 | os.chdir(directory) 80 | 81 | try: 82 | with open(filename) as csv_file: 83 | os.chdir(curr_dir) 84 | csv_reader = csv.reader(csv_file, delimiter=',') 85 | reachability_dict = {} 86 | for row in csv_reader: 87 | hostname = row[0] 88 | reachable = row[1:] 89 | 90 | reachable_set = set() 91 | for i in reachable: 92 | neighbour = i.split(",")[0] 93 | if not neighbour: 94 | continue 95 | port = int(i.split(",")[1]) 96 | reachable_set.add((neighbour, port)) 97 | 98 | reachability_dict[hostname] = reachable_set 99 | 100 | return reachability_dict 101 | 102 | except IOError: 103 | print("File {} does not exist".format(filename)) 104 | exit() 105 | 106 | # Creates 2 dictionaries: 107 | # 1) Mapping of (vulnName, vulnPort) to VulnerabilityNode 108 | # 2) Mapping of vulnName to vulnPort 109 | def parseVulnerabilities(self): 110 | file_input = input("Enter CSV file (including extension) containing vulnerabilities: ") 111 | filename = file_input.split("/")[-1] 112 | directory = file_input.replace(filename, '') 113 | 114 | if directory: 115 | curr_dir = os.getcwd() 116 | os.chdir(directory) 117 | 118 | try: 119 | with open(filename) as csv_file: 120 | os.chdir(curr_dir) 121 | next(csv_file, None) # Skip first row (header) 122 | csv_reader = csv.reader(csv_file, delimiter=',') 123 | 124 | vulnDict = {} 125 | portDict = {} 126 | for row in csv_reader: 127 | hostname = row[0] 128 | vulnName = row[1] 129 | vulnPort = int(row[2].split("/")[0]) 130 | vulnNode = VulnerabilityNode(vulnName, vulnPort) 131 | 132 | if (hostname, vulnPort) in vulnDict: 133 | vulnDict[(hostname, vulnPort)].add(vulnDict) 134 | else: 135 | vulnSet = set() 136 | vulnSet.add(VulnerabilityNode(vulnName, vulnPort)) 137 | vulnDict[(hostname, vulnPort)] = vulnSet 138 | 139 | if hostname in portDict: 140 | portDict[hostname].add(vulnPort) 141 | else: 142 | portSet = set() 143 | portSet.add(vulnPort) 144 | portDict[hostname] = portSet 145 | 146 | return vulnDict, portDict 147 | 148 | except IOError: 149 | print("File {} does not exist".format(filename)) 150 | exit() 151 | 152 | # Creates a dictionary mapping of CVE to event description 153 | # For simplicity, assumes each CVE is mapped to a single event 154 | # Use "cveToEvent.csv" as a sample 155 | def parseEventMapping(self): 156 | cveToEventDict = {} 157 | file_input = input("Enter CSV file (including extension) containing mapping of CVE to event: ") 158 | filename = file_input.split("/")[-1] 159 | directory = file_input.replace(filename, '') 160 | 161 | if directory: 162 | curr_dir = os.getcwd() 163 | os.chdir(directory) 164 | 165 | try: 166 | with open(filename) as csv_file: 167 | os.chdir(curr_dir) 168 | next(csv_file, None) # Skip first row (header) 169 | csv_reader = csv.reader(csv_file, delimiter=',') 170 | for row in csv_reader: 171 | cve = row[0] 172 | eventDescription = row[1] 173 | # if cve in cveToEventDict: 174 | # cveToEventDict[cve].add(eventDescription) 175 | # else: 176 | # eventSet = set() 177 | # eventSet.add(eventDescription) 178 | # cveToEventDict[cve] = eventSet 179 | cveToEventDict[cve] = eventDescription 180 | return cveToEventDict 181 | 182 | except IOError: 183 | print("File {} does not exist".format(filename)) 184 | exit() 185 | 186 | def parseSplunkConfig(self): 187 | try: 188 | f = open("splunkConfig.txt", "r") 189 | username = f.readline().strip('\n') 190 | password = f.readline().strip('\n') 191 | return username, password 192 | 193 | except IOError: 194 | print("File {} does not exist".format(filename)) 195 | exit() 196 | 197 | def parseCrownJewels(self): 198 | while True: 199 | try: 200 | numCrownJewels = int(input("Enter number of crown jewels in attack graph: >>>")) 201 | if numCrownJewels > 0: 202 | break 203 | print("Please enter a positive integer") 204 | except ValueError: 205 | print("Please enter a positive integer") 206 | 207 | print("Enter crown jewel(s) name(s) >>>") 208 | names = input() 209 | if not names: 210 | print("Please enter a non-empty crown jewel name") 211 | crownJewelNames = names.split(',') 212 | 213 | crownJewelSet = set() 214 | for i in range(0, numCrownJewels): 215 | for j in range(3): 216 | crownJewel = StateNode(crownJewelNames[i], j) 217 | crownJewelSet.add(crownJewel) 218 | 219 | # Reject if crown jewels do not exist 220 | # not implemented yet 221 | # currently, assume that crown jewels entered by user must exist 222 | 223 | # Reject if crown jewel set is empty 224 | if not crownJewelSet: 225 | print("Attack graph cannot have no crown jewels") 226 | exit() 227 | 228 | return crownJewelSet 229 | 230 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automating Cyber Incident Response 2 | 3 | A common problem faced by cyber incident responders is the enormous amount of logs detected by sensors. In order to reduce the time spent on analysing them and eliminate false positives, our program helps to: 4 | * Combine dynamic log information with static network information to automatically retrace the steps that the attacker took to conduct the intrusion 5 | * List the possible paths that the attacker might take and the vulnerabilities in the network that the attacker could exploit, to reach the crown jewels, so that defenders can remove these paths 6 | 7 | ## Getting Started 8 | 9 | These instructions will get you a copy of the project up and running on your local machine. 10 | 11 | ### Prerequisites 12 | 13 | #### Python 3.x 14 | 15 | * Visit https://www.python.org/downloads/ to download the latest suitable Python 3.x version according to your OS (we used Python 3.7 on Windows) 16 | 17 | #### Splunk and Splunk SDK for Python 18 | 19 | * Visit https://www.splunk.com/en_us/download.html and download the relevant edition of Splunk (we used the Splunk Enterprise 60-day trial) 20 | * Set up the Splunk service to be running on your local machine 21 | * Load your events into Splunk (we loaded `examples/eventSet.csv` as a local file to be monitored) 22 | * Visit http://dev.splunk.com/view/python-sdk/SP-CAAAEDG to download the latest Splunk SDK for Python (we used version 1.6.6) 23 | * Unpack and copy the downloaded package to the project folder, renaming the folder to `splunk`. Then install the splunk SDK using `python setup.py install` 24 | 25 | #### MongoDB 26 | 27 | * Visit https://www.mongodb.com/download-center/community to download the latest suitable Community server according to your OS (we used version 4.0.10 on Windows) 28 | * Set up MongoDB to run on `localhost:27017` 29 | 30 | ### Installation 31 | 32 | The following Python packages are required: 33 | 1. networkx 34 | 2. pymongo 35 | 3. BeautifulSoup4 36 | 4. LXML 37 | ``` 38 | pip install networkx 39 | pip install pymongo 40 | pip install BeautifulSoup4 41 | pip install LXML 42 | ``` 43 | 44 | ### Configuration Files 45 | 46 | These contain data about your network and its vulnerabilities. Sample configuration files can be found in the `examples` and `examples2` folder. 47 | 48 | #### Network Connectivity 49 | 50 | Each host in a network can connect to the open ports on certain other hosts but not others, depending on firewall configurations. Create a CSV file encapsulating this information i.e. the hosts and ports each host in your network can reach. (See `reachability.csv` for an example) 51 | 52 | * The first column contains the names of all hosts in your network 53 | * The subsequent columns contain the other hosts and its ports that are connected to the host in the first column. Each host and port pairing is in the format `,` 54 | * Each host is connected to its own open ports 55 | * For example, if host B has port 1521 open and is connected to host A at port 1521 and host C at port 80, the row should be `B,"A,1521","B,1521","C,80"` 56 | 57 | #### Vulnerabilities 58 | 59 | Create a CSV file that contains information about the vulnerability occurrences in the network. (See `vulnerabilities.csv` for an example) 60 | * The first column contains the name of the host having the vulnerability 61 | * The second column contains the CVE ID of the vulnerability occurence 62 | * The third column contains the vulnerability pertains to 63 | 64 | #### CVEs 65 | Create a CSV file that contains only the CVE ID of the vulnerabilities. This file is used by `web_scraping.py` (refer to the [next](#Running-the-Demo-Example) section) and has to contain minimally all the CVEs occuring in your network. (See `CVEs.csv` for an example) 66 | 67 | #### Mapping of CVEs to Events 68 | 69 | Create a CSV file that matches each CVE ID to an event description that the CVE manifests as. (See `cveToEvent.csv` for an example.) 70 | 71 | * The first column contains the CVE ID in the form CVE-year-number. 72 | * The second column contains the description of the event that happens when the corresponding CVE has been exploited. 73 | 74 | #### Splunk Credentials 75 | 76 | Replace `splunkConfig.txt` with your own `username` and `password` for Splunk. 77 | 78 | ### Running the Demo Example 79 | 80 | This section assumes the demo event set `eventSet.csv` has been loaded into Splunk. 81 | 82 | #### Preparation 83 | 84 | The script `web_scraping.py` extracts information about the CVEs in `CVEs.csv` from https://www.cvedetails.com and https://nvd.nist.gov/ and loads it into MongoDB for later querying. This script should be run whenever a new CVE is discovered within your network (i.e. the CVE configuration file must be updated to contain this new CVE). 85 | 86 | ``` 87 | > python scripts/web_scraping.py 88 | Enter CSV file (including extension) to read CVEs from: examples/CVEs.csv 89 | Successfully imported CVE details 90 | ``` 91 | #### Event Reconstruction 92 | 93 | Run the program `main.py` and input the entry points of the network (e.g. hosts A, B and C) and the configuration files: `reachability.csv`, `vulnerabilities.csv` and `cveToEvent.csv`. This generates the attack graph based on the given network topology and the vulnerabilities present in the network. 94 | 95 | Next, input a notable event with the assumed access level of the attacker. 96 | * Event: `, , , , , , destination port>, ` 97 | * Access Level: `0 (None), 1 (User) or 2 (Root)` 98 | 99 | ``` 100 | > python main.py 101 | Enter number of start nodes in attack graph: >>>3 102 | Enter start node(s) name(s), separated by comma >>>A,B,C 103 | Enter CSV file (including extension) containing reachability graph: examples/reachability.csv 104 | Enter CSV file (including extension) containing vulnerabilities: examples/vulnerabilities.csv 105 | Enter CSV file (including extension) containing mapping of CVE to event: examples/cveToEvent.csv 106 | 107 | Enter notable event >>> 108 | 1563861901,F,U,192.170.27.22,102.68.0.2,7654,1521,suspected data exfiltration 109 | Enter access level of attacker >>>2 110 | ``` 111 | 112 | #### Generate Possible Paths to Crown Jewels 113 | 114 | Lastly, input the crown jewels of the network for the program to generate all possible paths to the crown jewels from the node where the notable event occurred. 115 | 116 | ``` 117 | Enter number of crown jewels in attack graph: >>>2 118 | Enter crown jewel(s) name(s) >>> 119 | E,F 120 | ``` 121 | 122 | ##### Output 123 | 124 | 2 possible event sequences / attack paths are generated: 125 | ``` 126 | Entry: 1563861738, U, C, chunk-encoded HTTP request received 127 | -> 1563861790, C, A, user_save function called with an explicit category 128 | -> 1563861805, A, D, SQL query submitted via url 129 | -> 1563861880, D, F, XPC message sent to make a new OpenVPN connection 130 | -> Notable event: 1563861901, F, U, suspected data exfiltration 131 | 132 | Entry: 1563861738, U, C, chunk-encoded HTTP request received 133 | -> 1563861790, C, A, user_save function called with an explicit category 134 | -> 1563861805, A, D, SQL query submitted via url 135 | -> 1563861823, D, D, local user executes with root privilege 136 | -> 1563861880, D, F, XPC message sent to make a new OpenVPN connection 137 | -> Notable event: 1563861901, F, U, suspected data exfiltration 138 | ```` 139 | 140 | All possible paths to the crown jewels from the node where the notable event occurred are generated: 141 | ``` 142 | POSSIBLE PATHS TO REACH CROWN JEWEL E: 143 | Possible path 1: 144 | 1) notable event at (A, 1) 145 | 2) exploit CVE-2002-0392 on port 80 on node (C, 1) 146 | 3) exploit CVE-2016-1301 on port 80 on node (E, 1) 147 | Possible path 2: 148 | 1) notable event at (A, 1) 149 | 2) exploit CVE-2016-1393 on port 53 on node (D, 1) 150 | 3) exploit CVE-2018-1386 on port 1521 on node (D, 2) 151 | 4) exploit CVE-2016-1301 on port 80 on node (E, 1) 152 | Possible path 3: 153 | 1) notable event at (A, 1) 154 | 2) exploit CVE-2016-1393 on port 53 on node (D, 1) 155 | 3) exploit CVE-2018-1386 on port 1521 on node (D, 2) 156 | 4) exploit CVE-2018-9105 on port 1521 on node (F, 2) 157 | 5) exploit CVE-2016-1301 on port 80 on node (E, 1) 158 | Possible path 4: 159 | 1) notable event at (A, 1) 160 | 2) exploit CVE-2016-1393 on port 53 on node (D, 1) 161 | 3) exploit CVE-2016-1301 on port 80 on node (E, 1) 162 | Possible path 5: 163 | 1) notable event at (A, 1) 164 | 2) exploit CVE-2016-1393 on port 53 on node (D, 1) 165 | 3) exploit CVE-2018-9105 on port 1521 on node (F, 2) 166 | 4) exploit CVE-2016-1301 on port 80 on node (E, 1) 167 | Possible path 6: 168 | 1) notable event at (A, 1) 169 | 2) exploit CVE-2013-1534 on port 1521 on node (B, 0) 170 | 3) exploit CVE-2002-0392 on port 80 on node (C, 1) 171 | 4) exploit CVE-2016-1301 on port 80 on node (E, 1) 172 | POSSIBLE PATHS TO REACH CROWN JEWEL F: 173 | Possible path 1: 174 | 1) notable event at (A, 1) 175 | 2) exploit CVE-2002-0392 on port 80 on node (C, 1) 176 | 3) exploit CVE-2016-1301 on port 80 on node (E, 1) 177 | 4) exploit CVE-2018-9105 on port 1521 on node (F, 2) 178 | Possible path 2: 179 | 1) notable event at (A, 1) 180 | 2) exploit CVE-2002-0392 on port 80 on node (C, 1) 181 | 3) exploit CVE-2016-1301 on port 80 on node (E, 1) 182 | 4) exploit CVE-2016-1393 on port 53 on node (D, 1) 183 | 5) exploit CVE-2018-1386 on port 1521 on node (D, 2) 184 | 6) exploit CVE-2018-9105 on port 1521 on node (F, 2) 185 | Possible path 3: 186 | 1) notable event at (A, 1) 187 | 2) exploit CVE-2002-0392 on port 80 on node (C, 1) 188 | 3) exploit CVE-2016-1301 on port 80 on node (E, 1) 189 | 4) exploit CVE-2016-1393 on port 53 on node (D, 1) 190 | 5) exploit CVE-2018-9105 on port 1521 on node (F, 2) 191 | Possible path 4: 192 | 1) notable event at (A, 1) 193 | 2) exploit CVE-2016-1393 on port 53 on node (D, 1) 194 | 3) exploit CVE-2018-1386 on port 1521 on node (D, 2) 195 | 4) exploit CVE-2016-1301 on port 80 on node (E, 1) 196 | 5) exploit CVE-2018-9105 on port 1521 on node (F, 2) 197 | Possible path 5: 198 | 1) notable event at (A, 1) 199 | 2) exploit CVE-2016-1393 on port 53 on node (D, 1) 200 | 3) exploit CVE-2018-1386 on port 1521 on node (D, 2) 201 | 4) exploit CVE-2018-9105 on port 1521 on node (F, 2) 202 | Possible path 6: 203 | 1) notable event at (A, 1) 204 | 2) exploit CVE-2016-1393 on port 53 on node (D, 1) 205 | 3) exploit CVE-2016-1301 on port 80 on node (E, 1) 206 | 4) exploit CVE-2018-9105 on port 1521 on node (F, 2) 207 | Possible path 7: 208 | 1) notable event at (A, 1) 209 | 2) exploit CVE-2016-1393 on port 53 on node (D, 1) 210 | 3) exploit CVE-2018-9105 on port 1521 on node (F, 2) 211 | Possible path 8: 212 | 1) notable event at (A, 1) 213 | 2) exploit CVE-2013-1534 on port 1521 on node (B, 0) 214 | 3) exploit CVE-2002-0392 on port 80 on node (C, 1) 215 | 4) exploit CVE-2016-1301 on port 80 on node (E, 1) 216 | 5) exploit CVE-2018-9105 on port 1521 on node (F, 2) 217 | Possible path 9: 218 | 1) notable event at (A, 1) 219 | 2) exploit CVE-2013-1534 on port 1521 on node (B, 0) 220 | 3) exploit CVE-2002-0392 on port 80 on node (C, 1) 221 | 4) exploit CVE-2016-1301 on port 80 on node (E, 1) 222 | 5) exploit CVE-2016-1393 on port 53 on node (D, 1) 223 | 6) exploit CVE-2018-1386 on port 1521 on node (D, 2) 224 | 7) exploit CVE-2018-9105 on port 1521 on node (F, 2) 225 | Possible path 10: 226 | 1) notable event at (A, 1) 227 | 2) exploit CVE-2013-1534 on port 1521 on node (B, 0) 228 | 3) exploit CVE-2002-0392 on port 80 on node (C, 1) 229 | 4) exploit CVE-2016-1301 on port 80 on node (E, 1) 230 | 5) exploit CVE-2016-1393 on port 53 on node (D, 1) 231 | 6) exploit CVE-2018-9105 on port 1521 on node (F, 2) 232 | ``` 233 | 234 | Now, you are ready to go ahead and input your own configuration files! 235 | 236 | ## Developer Guide 237 | Refer [here](./DeveloperGuide.md) for the developer guide. 238 | 239 | ## Contributors 240 | 241 | * Charmaine Lee 242 | * Daryl Tew 243 | * Rebecca Tan 244 | --------------------------------------------------------------------------------