From bfd970171db38afea74835f670f1454d0e0479f8 Mon Sep 17 00:00:00 2001 From: Kent Ickler Date: Thu, 10 Dec 2020 03:34:03 -0700 Subject: [PATCH] start at code cleanup; PH in this branch will not work --- PlumHound.py | 397 ++------------------------- lib/__init__.py | 0 lib/phCLImanagement.py | 42 +++ lib/phCheckPython.py | 12 + lib/phDatabase.py | 34 +++ lib/phLoggy.py | 15 + lib/phdeliver.py | 130 +++++++++ lib/phtasks.py | 188 +++++++++++++ BlueHound.py => modules/BlueHound.py | 0 modules/__init__.py | 0 tasks/all.tasks | 38 +++ 11 files changed, 478 insertions(+), 378 deletions(-) create mode 100644 lib/__init__.py create mode 100644 lib/phCLImanagement.py create mode 100644 lib/phCheckPython.py create mode 100644 lib/phDatabase.py create mode 100644 lib/phLoggy.py create mode 100644 lib/phdeliver.py create mode 100644 lib/phtasks.py rename BlueHound.py => modules/BlueHound.py (100%) create mode 100644 modules/__init__.py create mode 100644 tasks/all.tasks diff --git a/PlumHound.py b/PlumHound.py index 61bde9a..0d150ed 100644 --- a/PlumHound.py +++ b/PlumHound.py @@ -1,391 +1,32 @@ #!/usr/bin/env python # -*- coding: utf8 -*- -# PlumHound v01.070a - -# PlumHound https://github.com/DefensiveOrigins/PlumHound | https://plumhound.defensiveorigins.com/ -# BloodHound Wrapper for Purple Teams -# ToolDropped May 13th 2020 as Proof of Concept Code - Black Hills Information Security #BHInfoSecurity #DefensiveOGs +# PlumHound https://github.com/PlumHound/PlumHound | https://plumhound.defensiveorigins.com/ +# BloodHoundAD Wrapper for Purple Teams | https://github.com/BloodHoundAD/BloodHound # GNU GPL 3.0 -# imports -import argparse -import sys -import ast -import csv -from neo4j import GraphDatabase -from tabulate import tabulate -from datetime import date -from BlueHound import * - - -def CheckPython(): - if sys.version_info < (3, 0, 0): - print(__file__ + ' requires Python 3, while Python ' + str(sys.version[0] + ' was detected. Terminating. ')) - sys.exit(1) - - -# ArgumentSetups -parser = argparse.ArgumentParser(description="BloodHound Wrapper for Blue/Purple Teams; v01.070a", add_help=True, epilog="For more information see https://plumhound.DefensiveOrigins.com") -pgroupc = parser.add_argument_group('DATABASE') -pgroupc.add_argument("-s", "--server", type=str, help="Neo4J Server", default="bolt://localhost:7687") -pgroupc.add_argument("-u", "--username", default="neo4j", type=str, help="Neo4J Database Useranme") -pgroupc.add_argument("-p", "--password", default="neo4jj", type=str, help="Neo4J Database Password") -pgroupc.add_argument("--UseEnc", default=False, dest="UseEnc", help="Use encryption when connecting.", action='store_true') - -pgroupx = parser.add_mutually_exclusive_group(required="True") -pgroupx.add_argument("--easy", help="Test Database Connection, Returns Domain Users to stdout", action='store_true') -pgroupx.add_argument("-x", "--TaskFile", dest="TaskFile", type=str, help="Specify a PlumHound TaskList File") -pgroupx.add_argument("-q," "--QuerySingle", dest="QuerySingle", type=str, help="Specify a Single Cypher Query") -pgroupx.add_argument("-bp," "--BusiestPath", dest="BusiestPath", nargs='+', default=False, type=str, help="Find the X Shortest Paths that give the most users a path to Domain Admins. Need to specified [short|all] for shortestpath and the number of results. Ex: PlumHound -cu all 3") -pgroupx.add_argument("-ap," "--AnalyzePath", dest="AnalyzePath", nargs='+', default=False, type=str, help="Analyze 'Attack Paths' between two nodes and find which path needs to be remediated to brake the path.") - - -pgroupo = parser.add_argument_group('OUTPUT', "Output Options (For single cypher queries only. --These options are ignored when -x or --easy is specified.") -pgroupo.add_argument("-t", "--title", dest="title", default="Adhoc Query", type=str, help="Report Title for Single Query [HTML,CSV,Latex]") -pgroupo.add_argument("--of", "--OutFile", dest="OutFile", default="PlumHoundReport", type=str, help="Specify a Single Cypher Query") -pgroupo.add_argument("--op", "--OutPath", dest="path", default="reports//", type=str, help="Specify an Output Path for Reports") -pgroupo.add_argument("--ox", "--OutFormat", dest="OutFormat", default="stdout", type=str, help="Specify the type of output", choices=['stdout', 'HTML', 'CSV']) - -pgrouph = parser.add_argument_group('HTML', "Options for HTML Output (For single queries or TaskLists") -pgrouph.add_argument("--HTMLHeader", dest="HTMLHeader", type=str, default="template//head.html", help="HTML Header (file) of Report") -pgrouph.add_argument("--HTMLFooter", dest="HTMLFooter", type=str, default="template//tail.html", help="HTML Footer (file) of Report") -pgrouph.add_argument("--HTMLCSS", dest="HTMLCSS", type=str, default="template//html.css", help="Specify a CSS template for HTML Output") - -pgroupv = parser.add_argument_group('VERBOSE' "Set verbosity") -pgroupv.add_argument("-v", "--verbose", type=int, default="100", help="Verbosity 0-1000, 0 = quiet") -args = parser.parse_args() - - -# Loggy Function for lazy debugging -def Loggy(level, notice): - if level <= args.verbose: - if level <= 100: - print("[*]" + notice) - elif level < 500: - print("[!]" + notice) - else: - print("[*]" + notice) - - -# Setup Database Connection -def setup_database_conn(server, username, password): - Loggy(900, "------ENTER: SETUP_DATABASE_CONN-----") - Loggy(200, "[!] Attempting to connect to your Neo4j project using {}:{} @ {} {}.".format(username, password, server, "[ENCRYPTED]" if args.UseEnc else "[UNECNCRYPTED]")) - try: - if args.UseEnc: - Loggy(200, " Using Neo4j encryption") - driver_connection = GraphDatabase.driver(server, auth=(username, password), encrypted=True) - else: - Loggy(200, " Not using Neo4j encryption") - driver_connection = GraphDatabase.driver(server, auth=(username, password), encrypted=False) - Loggy(200, "[+] Success!") - return driver_connection - except Exception: - Loggy(100, "There was a problem. Check username, password, and server parameters.") - Loggy(100, "[X] Database connection failed!") - exit() - Loggy(900, "------EXIT: SETUP_DATABASE_CONN-----") - - -def MakeTaskList(): - Loggy(900, "------ENTER: MAKETASKLIST-----") - Loggy(100, "Building Task List") - - tasks = [] - - if args.TaskFile: - Loggy(500, "Tasks file specified. Reading") - with open(args.TaskFile) as f: - tasks = f.read().splitlines() - Loggy(500, "TASKS: " + str(tasks)) - return tasks - - if args.QuerySingle: - Loggy(500, "Tasks Single Query Specified. Reading") - Loggy(500, "Tasks-Title:" + args.title) - Loggy(500, "Tasks-OutFormat:" + args.OutFormat) - Loggy(500, "Tasks-OutPath:" + args.path) - Loggy(500, "Tasks-QuerySingle:" + args.QuerySingle) - - task_str = "[\"" + args.title + "\",\"" + args.OutFormat + "\",\"" + args.OutFile + "\",\"" + args.QuerySingle + "\"]" - Loggy(500, "Task_str: " + task_str) - tasks = [task_str] - return tasks - - if args.BusiestPath: - # Find and print on screen the X Attack Paths that give the most users a path to DA - bp=find_busiest_path(args.server, args.username, args.password, args.BusiestPath[0], args.BusiestPath[1]) - - if args.AnalyzePath: - if args.AnalyzePath[0].upper() == "USER": - snode="User" - enode="" - elif args.AnalyzePath[0].upper() == "GROUP": - snode="Group" - enode="" - elif args.AnalyzePath[0].upper() == "COMPUTER": - snode="Computer" - enode="" - else: - snode=(args.AnalyzePath[0]).upper() - enode=(args.AnalyzePath[1]).upper() - getpaths(args.server, args.username, args.password,snode,enode) - - - if args.easy: - Loggy(500, "Tasks Easy Query Specified.") - tasks = ['["Domain Users","STDOUT","","MATCH (n:User) RETURN n.name, n.displayname"]'] - return tasks - - Loggy(100, "Tasks Generation Completed\nTasks: " + str(tasks)) - Loggy(900, "------EXIT: MAKETASKLIST-----") - return tasks - - -# Start Executions -def TaskExecution(tasks, Outpath, HTMLHeader, HTMLFooter, HTMLCSS): - Loggy(900, "------ENTER: TASKEXECUTION-----") - Loggy(500, "Begin Task Executions") - Loggy(500, "TASKS:" + str(tasks)) - - jobHTMLHeader = HTMLHeader - jobHTMLFooter = HTMLFooter - jobHTMLCSS = HTMLCSS - - task_output_list = [] - - for job in tasks: - try: - Loggy(200, "Starting job") - Loggy(500, "Job: " + str(job)) - - job_List = ast.literal_eval(job) - jobTitle = job_List[0] - jobOutFormat = job_List[1] - jobOutPathFile = Outpath + job_List[2] - jobQuery = job_List[3] - - Loggy(500, "Job Title: " + jobTitle) - Loggy(500, "Job Format: " + jobOutFormat) - Loggy(500, "Job File: " + jobOutPathFile) - Loggy(500, "Job Query: " + jobQuery) - - jobkeys = GetKeys(newdriver, jobQuery) - jobkeys_List = ast.literal_eval(str(jobkeys)) - # Quick fix if keys returned no record sto properly rebuild the keys list as 0 records, instead of int(0) - if isinstance(jobkeys_List, int): - jobkeys_List = [] - # - jobresults = execute_query(newdriver, jobQuery) - jobresults_processed = "[" + processresults(jobresults) + "]" - try: - jobresults_processed_list = ast.literal_eval(jobresults_processed) - except Exception: - Loggy(200, "ERROR While parsing results (non-fatal but errors may exist in output.") - Loggy(500, jobresults_processed) - jobresults_processed_list = jobresults_processed - - if jobOutFormat == "HTML": - task_output_list.append([jobTitle, len(jobresults_processed_list), job_List[2]]) - - Loggy(500, "Calling delivery service") - SenditOut(jobkeys_List, jobresults_processed_list, jobOutFormat, jobOutPathFile, "", jobTitle, jobHTMLHeader, jobHTMLFooter, jobHTMLCSS) - except Exception: - Loggy(200, "ERROR While trying to parse jobs (move along).") - Loggy(900, "------EXIT: TASKEXECUTION-----") - - if len(task_output_list) != 0: - FullSenditOut(task_output_list, Outpath, jobHTMLHeader, jobHTMLFooter, jobHTMLCSS) - -# Setup Query -def execute_query(driver, query, enabled=True): - Loggy(900, "------ENTER: EXECUTE_QUERY-----") - Loggy(500, "Executing things") - - with driver.session() as session: - Loggy(500, "Running Query") - results = session.run(query) - if check_records(results): - count = results.detach() - Loggy(500, "Identified " + str(count) + " Results") - else: - Loggy(200, "Shoot, nothing interesting was found") - Loggy(900, "------EXIT: EXECUTE_QUERY-----") - return results - - -# Grab Keys for Cypher Query -def GetKeys(driver, query, enabled=True): - Loggy(900, "------ENTER: GETKEYS-----") - Loggy(500, "Locating Keys") - Loggy(500, "GetKeys Query:" + str(query)) - with driver.session() as session: - results = session.run(query) - if check_records(results): - keys = results.keys() - Loggy(500, "Identified Keys: " + str(keys)) - else: - Loggy(200, "No Keys found, this won't go well") - keys = 0 - Loggy(500, "Key enumeration complete") - Loggy(900, "------EXIT: GETKEYS-----") - return keys - - -def check_records(results): - Loggy(900, "------ENTER: CHECK_RECORDS-----") - if results.peek(): - Loggy(500, "Peeking at things") - return True - else: - Loggy(200, "Nothing found to peek at") - return False - Loggy(900, "------EXIT: CHECK_RECORDS-----") - - -def processresults(results): - Loggy(900, "------ENTER: PROCESSRESULTS-----") - Loggy(500, "Results need washed") - BigTable = "" - for record in results: - try: - BigTable = BigTable + str(record.values()) + "," - except Exception: - Loggy(200, "Washing records failed. Error on record") - Loggy(900, "------EXIT: PROCESSRESULTS-----") - return BigTable - - -def SenditOut(list_KeysList, Processed_Results_List, OutFormat, OutFile, OutPath, Title, HTMLHeader, HTMLFooter, HTMLCSS): - Loggy(900, "------ENTER: SENDITOUT-----") - # Quick fix if keys returned no records to properly rebuild the keys list of 0, instead of int(0) - if isinstance(list_KeysList, int): - list_KeysList = [] - output = "" - - if OutFormat == "CSV": - Loggy(100, "Beginning Output CSV:" + OutPath + OutFile) - with open(OutPath + OutFile, "w", newline="") as f: - Loggy(500, "KeyType: " + str(type(list_KeysList))) - Loggy(500, "KeyList: " + str((list_KeysList))) - writer = csv.writer(f) - ModKeyList = ast.literal_eval("[" + str(list_KeysList) + "]") - Loggy(500, "KeyTypeMod: " + str(type(ModKeyList))) - Loggy(500, "KeyListMod: " + str(ModKeyList)) - writer.writerows(ModKeyList) - Loggy(500, "ResultsType: " + str(type(Processed_Results_List))) - Loggy(999, "ResultsList: " + str(Processed_Results_List)) - writer.writerows(Processed_Results_List) - return True - - if OutFormat == "STDOUT": - print() - output = tabulate(Processed_Results_List, list_KeysList, tablefmt="simple") - print(output) - return True - - if OutFormat == "HTML": - Loggy(100, "Beginning Output HTML:" + OutFile) - - output = tabulate(Processed_Results_List, list_KeysList, tablefmt="html") - HTMLCSS_str = "" - HTMLHeader_str = "" - HTMLFooter_str = "" - HTMLPre_str = "" - HTMLMId_str = "" - HTMLEnd_str = "" - if HTMLHeader: - with open(HTMLHeader, 'r') as header: - HTMLHeader_str = header.read() - HTMLHeader_str = ReplaceHTMLReportVars(HTMLHeader_str, Title) - - if HTMLFooter: - with open(HTMLFooter, 'r') as footer: - HTMLFooter_str = footer.read() - HTMLFooter_str = ReplaceHTMLReportVars(HTMLFooter_str, Title) - - if HTMLCSS: - with open(HTMLCSS, 'r') as css: - HTMLCSS_str = "" - - Loggy(500, "File Writing " + OutPath + OutFile) - output = HTMLPre_str + HTMLCSS_str + HTMLMId_str + HTMLHeader_str + output + HTMLFooter_str + HTMLEnd_str - fsys = open(OutPath + OutFile, "w") - fsys.write(output) - fsys.close - return True - Loggy(900, "------EXIT: SENDITOUT-----") - - -def FullSenditOut(Processed_Results_List, OutPath, HTMLHeader, HTMLFooter, HTMLCSS): - Loggy(900, "------ENTER: FULLSENDITOUT-----") - - list_KeysList = ["Title", "Count", "Further Details"] - OutFile = "Report.html" - Title = "Full Report Details" - - Loggy(100, "Beginning Output HTML:" + OutFile) - - for entry in Processed_Results_List: - filename = entry[2] - entry[2] = "Details" - - output = str(tabulate(Processed_Results_List, list_KeysList, tablefmt="html")) - output = output.replace("<","<") - output = output.replace(">",">") - output = output.replace(""",'"') - - HTMLCSS_str = "" - HTMLHeader_str = "" - HTMLFooter_str = "" - HTMLPre_str = "" - HTMLMId_str = "" - HTMLEnd_str = "" - if HTMLHeader: - with open(HTMLHeader, 'r') as header: - HTMLHeader_str = header.read() - HTMLHeader_str = ReplaceHTMLReportVars(HTMLHeader_str, Title) - - if HTMLFooter: - with open(HTMLFooter, 'r') as footer: - HTMLFooter_str = footer.read() - HTMLFooter_str = ReplaceHTMLReportVars(HTMLFooter_str, Title) - - if HTMLCSS: - with open(HTMLCSS, 'r') as css: - HTMLCSS_str = "" - - Loggy(500, "File Writing " + OutPath + OutFile) - output = HTMLPre_str + HTMLCSS_str + HTMLMId_str + HTMLHeader_str + output + HTMLFooter_str + HTMLEnd_str - fsys = open(OutPath + OutFile, "w") - fsys.write(output) - fsys.close - Loggy(100, "Full report written to Report.html") - return True - Loggy(900, "------EXIT: FULLSENDITOUT-----") - - -def ReplaceHTMLReportVars(InputStr, Title): - sOutPut = InputStr.replace("--------PH_TITLE-------", str(Title)) - sOutPut = sOutPut.replace("--------PH_DATE-------", str(date.today())) - return sOutPut +#Import PlumHound libraries +import lib.phCheckPython +import lib.phCLImanagement +import lib.phTasks +import lib.phDatabase +# Check for Python3 environment. If not executing in Python3, exit nicely. +lib.phCheckPython.CheckPython() -# Check for Python3 environment. -CheckPython() +# Commandline Arguments (ArgParse) configuration +phArgs = lib.phCLImanagement.SetupArguments() -# Setup Driver -newdriver = setup_database_conn(args.server, args.username, args.password) +# Generate TaskList (jobs) +phTaskList = lib.phTasks.MakeTaskList(phArgs) -# Read Task List -TaskList = MakeTaskList() +# Setup Driver (excluding BlueHound) +phDriver = lib.phDatabase.setup_database_conn(phArgs) -# Start Task List -TaskExecution(TaskList, args.path, args.HTMLHeader, args.HTMLFooter, args.HTMLCSS) +# Execute Jobs in Task List +lib.phTasks.TaskExecution(phTaskList, phDriver, phArgs) -# Close out neo4j connection -newdriver.close +# Close the neoj4 connection. +lib.phDatabase.close_database_con(phArgs,phDriver) -# END diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/phCLImanagement.py b/lib/phCLImanagement.py new file mode 100644 index 0000000..cd95051 --- /dev/null +++ b/lib/phCLImanagement.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf8 -*- +# PlumHound (phCLIManagement.py) - Management of Command Line Arguments +# https://github.com/PlumHound/PlumHound +# License GNU GPL3 + +# Import Python Modules +import argparse + +def SetupArguments(): + parser = argparse.ArgumentParser(description="BloodHound Wrapper for Blue/Purple Teams; v01.070a", add_help=True, epilog="For more information see https://plumhound.DefensiveOrigins.com") + pgroupc = parser.add_argument_group('DATABASE') + pgroupc.add_argument("-s", "--server", type=str, help="Neo4J Server", default="bolt://localhost:7687") + pgroupc.add_argument("-u", "--username", default="neo4j", type=str, help="Neo4J Database Useranme") + pgroupc.add_argument("-p", "--password", default="neo4jj", type=str, help="Neo4J Database Password") + pgroupc.add_argument("--UseEnc", default=False, dest="UseEnc", help="Use encryption when connecting.", action='store_true') + + pgroupx = parser.add_mutually_exclusive_group(required="True") + pgroupx.add_argument("--easy", help="Test Database Connection, Returns Domain Users to stdout", action='store_true') + pgroupx.add_argument("-x", "--TaskFile", dest="TaskFile", type=str, help="Specify a PlumHound TaskList File") + pgroupx.add_argument("-q," "--QuerySingle", dest="QuerySingle", type=str, help="Specify a Single Cypher Query") + pgroupx.add_argument("-bp," "--BusiestPath", dest="BusiestPath", nargs='+', default=False, type=str, help="Find the X Shortest Paths that give the most users a path to Domain Admins. Need to specified [short|all] for shortestpath and the number of results. Ex: PlumHound -cu all 3") + pgroupx.add_argument("-ap," "--AnalyzePath", dest="AnalyzePath", nargs='+', default=False, type=str, help="Analyze 'Attack Paths' between two nodes and find which path needs to be remediated to brake the path.") + + pgroupo = parser.add_argument_group('OUTPUT', "Output Options (For single cypher queries only. --These options are ignored when -x or --easy is specified.") + pgroupo.add_argument("-t", "--title", dest="title", default="Adhoc Query", type=str, help="Report Title for Single Query [HTML,CSV,Latex]") + pgroupo.add_argument("--of", "--OutFile", dest="OutFile", default="PlumHoundReport", type=str, help="Specify a Single Cypher Query") + pgroupo.add_argument("--op", "--OutPath", dest="path", default="reports//", type=str, help="Specify an Output Path for Reports") + pgroupo.add_argument("--ox", "--OutFormat", dest="OutFormat", default="stdout", type=str, help="Specify the type of output", choices=['stdout', 'HTML', 'CSV']) + + pgrouph = parser.add_argument_group('HTML', "Options for HTML Output (For single queries or TaskLists") + pgrouph.add_argument("--HTMLHeader", dest="HTMLHeader", type=str, default="template//head.html", help="HTML Header (file) of Report") + pgrouph.add_argument("--HTMLFooter", dest="HTMLFooter", type=str, default="template//tail.html", help="HTML Footer (file) of Report") + pgrouph.add_argument("--HTMLCSS", dest="HTMLCSS", type=str, default="template//html.css", help="Specify a CSS template for HTML Output") + + pgroupv = parser.add_argument_group('VERBOSE' "Set verbosity") + pgroupv.add_argument("-v", "--verbose", type=int, default="100", help="Verbosity 0-1000, 0 = quiet") + + args = parser.parse_args() + + return args + diff --git a/lib/phCheckPython.py b/lib/phCheckPython.py new file mode 100644 index 0000000..1b65b8e --- /dev/null +++ b/lib/phCheckPython.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +# -*- coding: utf8 -*- +# PlumHound (phCheckPython.py) - Check for appropriate version of python +# https://github.com/PlumHound/PlumHound +# License GNU GPL3 + +import sys + +def CheckPython(): + if sys.version_info < (3, 0, 0): + print(__file__ + ' requires Python 3, while Python ' + str(sys.version[0] + ' was detected. Terminating. ')) + sys.exit(1) \ No newline at end of file diff --git a/lib/phDatabase.py b/lib/phDatabase.py new file mode 100644 index 0000000..35c7f7c --- /dev/null +++ b/lib/phDatabase.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# -*- coding: utf8 -*- +# PlumHound (phDatabase.py) Database Connection Management +# https://github.com/PlumHound/PlumHound +# License GNU GPL3 + +from neo4j import GraphDatabase +from lib.phLoggy import Loggy as Loggy + +# Setup Database Connection +def setup_database_conn(phArgs): + Loggy(phArgs.verbose,900, "------ENTER: SETUP_DATABASE_CONN-----") + Loggy(phArgs.verbose,200, "[!] Attempting to connect to your Neo4j project using {}:{} @ {} {}.".format(phArgs.username, phArgs.password, phArgs.server, "[ENCRYPTED]" if args.UseEnc else "[UNECNCRYPTED]")) + try: + if args.UseEnc: + Loggy(phArgs.verbose,200, " Using Neo4j encryption") + driver_connection = GraphDatabase.driver(phArgs.server, auth=(phArgs.username, phArgs.password), encrypted=True) + else: + Loggy(phArgs.verbose,200, " Not using Neo4j encryption") + driver_connection = GraphDatabase.driver(phArgs.server, auth=(phArgs.username, phArgs.password), encrypted=False) + Loggy(phArgs.verbose,200, "[+] Success!") + return driver_connection + except Exception: + Loggy(phArgs.verbose,100, "There was a problem. Check username, password, and server parameters.") + Loggy(phArgs.verbose,100, "[X] Database connection failed!") + exit() + Loggy(phArgs.verbose,900, "------EXIT: SETUP_DATABASE_CONN-----") + +def close_database_con(phArgs,connectiondriver): + Loggy(phArgs.verbose,900, "------ENTER: CLOSE_DATABASE_CONN-----") + connectiondriver.close + return False + Loggy(phArgs.verbose,200, "[+] Closed Database Connection!") + exit() diff --git a/lib/phLoggy.py b/lib/phLoggy.py new file mode 100644 index 0000000..e8ec945 --- /dev/null +++ b/lib/phLoggy.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +# -*- coding: utf8 -*- +# PlumHound (phLoggy.py) - Logging Function +# https://github.com/PlumHound/PlumHound +# License GNU GPL3 + +# Loggy Function for lazy debugging +def Loggy(hurdle, level, notice): + if level <= hurdle: + if level <= 100: + print("[*]" + notice) + elif level < 500: + print("[!]" + notice) + else: + print("[*]" + notice) \ No newline at end of file diff --git a/lib/phdeliver.py b/lib/phdeliver.py new file mode 100644 index 0000000..3e94341 --- /dev/null +++ b/lib/phdeliver.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# -*- coding: utf8 -*- +# PlumHound - Output Delivery and Parsing Functions(phdeliver.py) +# https://github.com/PlumHound/PlumHound +# License GNU GPL3 + +#Python Libraries +import sys +import ast +import csv +from tabulate import tabulate +from datetime import date + +#Plumhound Modules +from lib.phLoggy import Loggy as Loggy + + +def SenditOut(list_KeysList, Processed_Results_List, OutFormat, OutFile, OutPath, Title, HTMLHeader, HTMLFooter, HTMLCSS): + Loggy(900, "------ENTER: SENDITOUT-----") + # Quick fix if keys returned no records to properly rebuild the keys list of 0, instead of int(0) + if isinstance(list_KeysList, int): + list_KeysList = [] + output = "" + + if OutFormat == "CSV": + Loggy(100, "Beginning Output CSV:" + OutPath + OutFile) + with open(OutPath + OutFile, "w", newline="") as f: + Loggy(500, "KeyType: " + str(type(list_KeysList))) + Loggy(500, "KeyList: " + str((list_KeysList))) + writer = csv.writer(f) + ModKeyList = ast.literal_eval("[" + str(list_KeysList) + "]") + Loggy(500, "KeyTypeMod: " + str(type(ModKeyList))) + Loggy(500, "KeyListMod: " + str(ModKeyList)) + writer.writerows(ModKeyList) + Loggy(500, "ResultsType: " + str(type(Processed_Results_List))) + Loggy(999, "ResultsList: " + str(Processed_Results_List)) + writer.writerows(Processed_Results_List) + return True + + if OutFormat == "STDOUT": + print() + output = tabulate(Processed_Results_List, list_KeysList, tablefmt="simple") + print(output) + return True + + if OutFormat == "HTML": + Loggy(100, "Beginning Output HTML:" + OutFile) + + output = tabulate(Processed_Results_List, list_KeysList, tablefmt="html") + HTMLCSS_str = "" + HTMLHeader_str = "" + HTMLFooter_str = "" + HTMLPre_str = "" + HTMLMId_str = "" + HTMLEnd_str = "" + if HTMLHeader: + with open(HTMLHeader, 'r') as header: + HTMLHeader_str = header.read() + HTMLHeader_str = ReplaceHTMLReportVars(HTMLHeader_str, Title) + + if HTMLFooter: + with open(HTMLFooter, 'r') as footer: + HTMLFooter_str = footer.read() + HTMLFooter_str = ReplaceHTMLReportVars(HTMLFooter_str, Title) + + if HTMLCSS: + with open(HTMLCSS, 'r') as css: + HTMLCSS_str = "" + + Loggy(500, "File Writing " + OutPath + OutFile) + output = HTMLPre_str + HTMLCSS_str + HTMLMId_str + HTMLHeader_str + output + HTMLFooter_str + HTMLEnd_str + fsys = open(OutPath + OutFile, "w") + fsys.write(output) + fsys.close + return True + Loggy(900, "------EXIT: SENDITOUT-----") + + +def FullSenditOut(Processed_Results_List, OutPath, HTMLHeader, HTMLFooter, HTMLCSS): + Loggy(900, "------ENTER: FULLSENDITOUT-----") + + list_KeysList = ["Title", "Count", "Further Details"] + OutFile = "Report.html" + Title = "Full Report Details" + + Loggy(100, "Beginning Output HTML:" + OutFile) + + for entry in Processed_Results_List: + filename = entry[2] + entry[2] = "Details" + + output = str(tabulate(Processed_Results_List, list_KeysList, tablefmt="html")) + output = output.replace("<","<") + output = output.replace(">",">") + output = output.replace(""",'"') + + HTMLCSS_str = "" + HTMLHeader_str = "" + HTMLFooter_str = "" + HTMLPre_str = "" + HTMLMId_str = "" + HTMLEnd_str = "" + if HTMLHeader: + with open(HTMLHeader, 'r') as header: + HTMLHeader_str = header.read() + HTMLHeader_str = ReplaceHTMLReportVars(HTMLHeader_str, Title) + + if HTMLFooter: + with open(HTMLFooter, 'r') as footer: + HTMLFooter_str = footer.read() + HTMLFooter_str = ReplaceHTMLReportVars(HTMLFooter_str, Title) + + if HTMLCSS: + with open(HTMLCSS, 'r') as css: + HTMLCSS_str = "" + + Loggy(500, "File Writing " + OutPath + OutFile) + output = HTMLPre_str + HTMLCSS_str + HTMLMId_str + HTMLHeader_str + output + HTMLFooter_str + HTMLEnd_str + fsys = open(OutPath + OutFile, "w") + fsys.write(output) + fsys.close + Loggy(100, "Full report written to Report.html") + return True + Loggy(900, "------EXIT: FULLSENDITOUT-----") + + +def ReplaceHTMLReportVars(InputStr, Title): + sOutPut = InputStr.replace("--------PH_TITLE-------", str(Title)) + sOutPut = sOutPut.replace("--------PH_DATE-------", str(date.today())) + return sOutPut \ No newline at end of file diff --git a/lib/phtasks.py b/lib/phtasks.py new file mode 100644 index 0000000..370e4f6 --- /dev/null +++ b/lib/phtasks.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +# -*- coding: utf8 -*- +# PlumHound (phtasks.py) - Task Management and Execution +# https://github.com/PlumHound/PlumHound +# License GNU GPL3 + +#Python Libraries +import sys +from tabulate import tabulate +from datetime import date +from neo4j import GraphDatabase + +#Plumhound modules +from lib.phLoggy import Loggy as Loggy +from lib.phDeliver import * + +#Plumhound Extensions +from modules.BlueHound import * + + +def MakeTaskList(phArgs): + Loggy(phArgs.verbose,900, "------ENTER: MAKETASKLIST-----") + Loggy(phArgs.verbose,100, "Building Task List") + + tasks = [] + + if phArgs.TaskFile: + Loggy(phArgs.verbose,500, "Tasks file specified. Reading") + with open(phArgs.TaskFile) as f: + tasks = f.read().splitlines() + Loggy(phArgs.verbose,500, "TASKS: " + str(tasks)) + return tasks + + if phArgs.QuerySingle: + Loggy(phArgs.verbose,500, "Tasks Single Query Specified. Reading") + Loggy(phArgs.verbose,500, "Tasks-Title:" + phArgs.title) + Loggy(phArgs.verbose,500, "Tasks-OutFormat:" + phArgs.OutFormat) + Loggy(phArgs.verbose,500, "Tasks-OutPath:" + phArgs.path) + Loggy(phArgs.verbose,500, "Tasks-QuerySingle:" + phArgs.QuerySingle) + + task_str = "[\"" + phArgs.title + "\",\"" + phArgs.OutFormat + "\",\"" + phArgs.OutFile + "\",\"" + phArgs.QuerySingle + "\"]" + Loggy(phArgs.verbose,500, "Task_str: " + task_str) + tasks = [task_str] + return tasks + + if args.BusiestPath: + # Find and print on screen the X Attack Paths that give the most users a path to DA + bp=find_busiest_path(phArgs.server, phArgs.username, phArgs.password, phArgs.BusiestPath[0], phArgs.BusiestPath[1]) + + if phArgs.AnalyzePath: + if phArgs.AnalyzePath[0].upper() == "USER": + snode="User" + enode="" + elif phArgs.AnalyzePath[0].upper() == "GROUP": + snode="Group" + enode="" + elif phArgs.AnalyzePath[0].upper() == "COMPUTER": + snode="Computer" + enode="" + else: + snode=(phArgs.AnalyzePath[0]).upper() + enode=(phArgs.AnalyzePath[1]).upper() + BueHound.getpaths(phArgs.server, phArgs.username, phArgs.password,snode,enode) + + if phArgs.easy: + Loggy(phArgs.verbose,500, "Tasks Easy Query Specified.") + tasks = ['["Domain Users","STDOUT","","MATCH (n:User) RETURN n.name, n.displayname"]'] + return tasks + + Loggy(phArgs.verbose,100, "Tasks Generation Completed\nTasks: " + str(tasks)) + Loggy(phArgs.verbose,900, "------EXIT: MAKETASKLIST-----") + return tasks + + +# Execute Tasks +def TaskExecution(verbose,tasks, phDriver, phArgs): + + Loggy(phArgs.verbose,900, "------ENTER: TASKEXECUTION-----") + Loggy(phArgs.verbose,500, "Begin Task Executions") + Loggy(phArgs.verbose,500, "TASKS:" + str(tasks)) + + Outpath=phArgs.path + jobHTMLHeader = phArgs.HTMLHeader + jobHTMLFooter = phArgs.HTMLFooter + jobHTMLCSS = phArgs.HTMLCSS + + task_output_list = [] + + for job in tasks: + try: + Loggy(phArgs.verbose,200, "Starting job") + Loggy(phArgs.verbose,500, "Job: " + str(job)) + + job_List = ast.literal_eval(job) + jobTitle = job_List[0] + jobOutFormat = job_List[1] + jobOutPathFile = Outpath + job_List[2] + jobQuery = job_List[3] + + Loggy(phArgs.verbose,500, "Job Title: " + jobTitle) + Loggy(phArgs.verbose,500, "Job Format: " + jobOutFormat) + Loggy(phArgs.verbose,500, "Job File: " + jobOutPathFile) + Loggy(phArgs.verbose,500, "Job Query: " + jobQuery) + + jobkeys = GetKeys(phArgs.verbose,phDriver, jobQuery) + jobkeys_List = ast.literal_eval(str(jobkeys)) + # Quick fix if keys returned no records to properly rebuild the keys list as 0 records, instead of int(0) + if isinstance(jobkeys_List, int): + jobkeys_List = [] + # + jobresults = execute_query(phArgs.verbose,phDriver, jobQuery) + jobresults_processed = "[" + processresults(phArgs.verbose,jobresults) + "]" + try: + jobresults_processed_list = ast.literal_eval(jobresults_processed) + except Exception: + Loggy(phArgs.verbose,200, "ERROR While parsing results (non-fatal but errors may exist in output.") + Loggy(phArgs.verbose,500, jobresults_processed) + jobresults_processed_list = jobresults_processed + + if jobOutFormat == "HTML": + task_output_list.append([jobTitle, len(jobresults_processed_list), job_List[2]]) + + Loggy(phArgs.verbose,500, "Calling delivery service") + SenditOut(jobkeys_List, jobresults_processed_list, jobOutFormat, jobOutPathFile, "", jobTitle, jobHTMLHeader, jobHTMLFooter, jobHTMLCSS) + except Exception: + Loggy(phArgs.verbose,200, "ERROR While trying to parse jobs (move along).") + Loggy(phArgs.verbose,900, "------EXIT: TASKEXECUTION-----") + + if len(task_output_list) != 0: + FullSenditOut(task_output_list, Outpath, jobHTMLHeader, jobHTMLFooter, jobHTMLCSS) + +# Setup Query +def execute_query(verbose,phDriver, query, enabled=True): + Loggy(verbose,900, "------ENTER: EXECUTE_QUERY-----") + Loggy(verbose,500, "Executing things") + + with phDriver.session() as session: + Loggy(verbose,500, "Running Query") + results = session.run(query) + if check_records(verbose,results): + count = results.detach() + Loggy(verbose,500, "Identified " + str(count) + " Results") + else: + Loggy(verbose,200, "Shoot, nothing interesting was found") + Loggy(verbose,900, "------EXIT: EXECUTE_QUERY-----") + return results + + +# Grab Keys for Cypher Query +def GetKeys(verbose,phDriver, query, enabled=True): + Loggy(verbose,900, "------ENTER: GETKEYS-----") + Loggy(verbose,500, "Locating Keys") + Loggy(verbose,500, "GetKeys Query:" + str(query)) + with phDriver.session() as session: + results = session.run(query) + if check_records(verbose,results): + keys = results.keys() + Loggy(args.verbose,500, "Identified Keys: " + str(keys)) + else: + Loggy(args.verbose,200, "No Keys found, this won't go well") + keys = 0 + Loggy(args.verbose,500, "Key enumeration complete") + Loggy(args.verbose,900, "------EXIT: GETKEYS-----") + return keys + + +def check_records(verbose,results): + Loggy(verbose,900, "------ENTER: CHECK_RECORDS-----") + if results.peek(): + Loggy(verbose,500, "Peeking at things") + return True + else: + Loggy(verbose,200, "Nothing found to peek at") + return False + Loggy(verbose,900, "------EXIT: CHECK_RECORDS-----") + + +def processresults(verbose,results): + Loggy(verbose,900, "------ENTER: PROCESSRESULTS-----") + Loggy(verbose,500, "Results need washed") + BigTable = "" + for record in results: + try: + BigTable = BigTable + str(record.values()) + "," + except Exception: + Loggy(verbose,200, "Washing records failed. Error on record") + Loggy(verbose,900, "------EXIT: PROCESSRESULTS-----") + return BigTable diff --git a/BlueHound.py b/modules/BlueHound.py similarity index 100% rename from BlueHound.py rename to modules/BlueHound.py diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/all.tasks b/tasks/all.tasks new file mode 100644 index 0000000..3cc6b98 --- /dev/null +++ b/tasks/all.tasks @@ -0,0 +1,38 @@ +["Domain Users", "HTML", "DomainUsers.html", "MATCH (n:User) RETURN n.name as Name,n.displayname as DisplayName,n.enabled as Enabled, n.highvalue as HighValue, n.description as Description, n.title as Title, n.pwdneverexpires as PWDNeverExpires, n.passwordnotreqd as PWDNotReqd, n.sensitive as Sensitive, n.admincount as AdminCount, n.serviceprincipalnames as SPN, toString(datetime({epochSeconds: ToInteger(coalesce(n.pwdlastset,0))})) as PWDLastSet, toString(datetime({epochSeconds: ToInteger(coalesce(n.lastlogon,0))})) as LastLogon" ] +["Domain Controllers","HTML","DomainControllers.html","MATCH (c:Computer)-[:MemberOf*1..]->(g:Group) WHERE g.objectid ENDS WITH '-516' RETURN c.name as Hostname, c.operatingsystem as OS, c.enabled as Enabled"] +["Keroastable Users","HTML","Keroastable_Users.html","MATCH (n:User) WHERE n.hasspn=true RETURN n.name as Username, n.displayname as DisplayName,n.description as Description, n.title as Title, n.pwdneverexpires as PasswordNeverExpires, n.passwordnotreqd as PasswordNotRequired, n.sensitive as Sensitive, n.admincount as AdminCount, n.serviceprincipalnames as SPNs"] +["RDPable Servers","HTML","Workstations_RDP.html","match p=(g:Group)-[:CanRDP]->(c:Computer) where g.objectid ENDS WITH \'-513\' AND c.operatingsystem CONTAINS \'Server\' return c.name as Computer"] +["Unconstrained Delegation Computers with SPN","HTML","Computers_UnconstrainedDelegation.html","MATCH (c:Computer {unconstraineddelegation:true}) return c.name as Computer, c.description as Description, c.serviceprincipalnames as SPN"] +["Admin Groups","HTML","AdminGroups.html","Match (n:Group) WHERE n.name CONTAINS \'ADMIN\' return n.name as Name, n.highvalue as HighValue, n.description as Description, n.admincount as AdminCount"] +["RDPable Groups", "HTML", "RDPableGroups.html", "MATCH p=(m:Group)-[r:CanRDP]->(n:Computer) RETURN m.name as Group, n.name as Computer ORDER BY m.name" ] +["RDPable Groups Count", "HTML", "RDPableGroupsCount.html", "MATCH p=(m:Group)-[r:CanRDP]->(n:Computer) RETURN m.name as Group, count(*) as Computer ORDER BY Computer DESC" ] +["PasswordResetter Groups", "HTML", "Groups_CanResetPasswords.html", "MATCH p=(m:Group)-[r:ForceChangePassword]->(n:User) RETURN m.name as Group, n.name ORDER BY m.name as User" ] +["PasswordResetter Groups Count", "HTML", "Groups_CanResetPasswordsCount.html", "MATCH p=(m:Group)-[r:ForceChangePassword]->(n:User) RETURN m.name as Group, count(*) as Users ORDER BY Users DESC" ] +["LocalAdminGroups", "HTML", "LocalAdmin_Groups.html", "MATCH p=(m:Group)-[r:AdminTo]->(n:Computer) RETURN m.name as Group, n.name as Computer ORDER BY m.name" ] +["LocalAdminGroupsCount", "HTML", "LocalAdmin_Groups_Count.html", "MATCH p=(m:Group)-[r:AdminTo]->(n:Computer) RETURN m.name as Group, count(*) as Computer ORDER BY Computer DESC"] +["LocalAdminUsers","HTML","LocalAdmin_Users.html","MATCH p=(m:User)-[r:AdminTo]->(n:Computer) RETURN m.name as User, n.name as Computer ORDER BY m.name"] +["LocalAdminUsers", "HTML", "LocalAdmin_Users.html", "MATCH p=(m:User)-[r:AdminTo]->(n:Computer) RETURN m.name as User, count(*) as Computer ORDER BY Computer DESC" ] +["Users Sessions", "HTML", "Users_Sessions.html", "MATCH p=(n:User)--(c:Computer)-[:HasSession]->(n) return n.name as User, c.name as Computer ORDER BY n.name"] +["Users Sessions Count", "HTML", "Users_Sessions_Count.html", "MATCH p=(n:User)--(c:Computer)-[:HasSession]->(n) return n.name as User, count(*) as Computers ORDER BY Computers DESC"] +["Cross Domain Relationships", "HTML", "CrossDomainRelationships.html", "MATCH (n)-[r]->(m) WHERE NOT n.domain = m.domain RETURN LABELS(n)[0] as Dom1Object ,n.name as Object1 ,TYPE(r) as Relationship ,LABELS(m)[0] as Dom2Object,m.name as Object2"] +["DA Sessions","HTML","DA_Sessions.html","MATCH (n:User)-[:MemberOf]->(g:Group) WHERE g.objectid ENDS WITH \'-512\' MATCH p = (c:Computer)-[:HasSession]->(n) return n.name as Username, c.name as Computer"] +["EA Sessions","HTML","EA_Sessions.html","MATCH (n:User)-[:MemberOf]->(g:Group) WHERE g.objectid ENDS WITH \'-519\' MATCH p = (c:Computer)-[:HasSession]->(n) return n.name as Username, c.name as Computer"] +["Keroastable Most Priv","HTML","Keroastable_Users_MostPriv.html","MATCH (u:User {hasspn:true}) OPTIONAL MATCH (u)-[:AdminTo]->(c1:Computer) OPTIONAL MATCH (u)-[:MemberOf*1..]->(:Group)-[:AdminTo]->(c2:Computer) WITH u,COLLECT(c1) + COLLECT(c2) AS tempVar UNWIND tempVar AS comps RETURN u.name as KeroastableUser,COUNT(DISTINCT(comps)) as Computers ORDER BY COUNT(DISTINCT(comps)) DESC"] +["OUs By Computer Member Count","HTML","OUs_Count.html","MATCH (o:OU)-[:Contains]->(c:Computer) RETURN o.name as OU,COUNT(c) as Computers ORDER BY COUNT(c) DESC"] +["Permissions for Everyone and Authenticated Users","HTML","Permissions_Everyone.html","MATCH p=(m:Group)-[r:AddMember|AdminTo|AllExtendedRights|AllowedToDelegate|CanRDP|Contains|ExecuteDCOM|ForceChangePassword|GenericAll|GenericWrite|GetChanges|GetChangesAll|HasSession|Owns|ReadLAPSPassword|SQLAdmin|TrustedBy|WriteDACL|WriteOwner|AddAllowedToAct|AllowedToAct]->(t) WHERE m.objectsid ENDS WITH \'-513\' OR m.objectsid ENDS WITH \'-515\' OR m.objectsid ENDS WITH \'S-1-5-11\' OR m.objectsid ENDS WITH \'S-1-1-0\' RETURN m.name as Group,TYPE(r) as Relationship,t.name as TargetNode,t.enabledTargetEnabled"] +["Most Admin Priviledged Groups","HTML","Groups_MostAdminPriviledged.html","MATCH (g:Group) OPTIONAL MATCH (g)-[:AdminTo]->(c1:Computer) OPTIONAL MATCH (g)-[:MemberOf*1..]->(:Group)-[:AdminTo]->(c2:Computer) WITH g, COLLECT(c1) + COLLECT(c2) AS tempVar UNWIND tempVar AS computers RETURN g.name AS GroupName,COUNT(DISTINCT(computers)) AS AdminRightCount ORDER BY AdminRightCount DESC"] +["Computers with Descriptions","HTML","Computers_WithDescriptions.html","MATCH (c:Computer) WHERE c.description IS NOT NULL RETURN c.name as Computer,c.description as Description"] +["User No Kerb Needed","HTML","Users_NoKerbReq.html","MATCH (n:User {dontreqpreauth: true}) RETURN n.name as Username, n.displayname as DisplayName, n.description as Description, n.title as Title, n.pwdneverexpires as PasswordNeverExpires, n.passwordnotreqd as PasswordNotRequired, n.sensitive as Sensitive, n.admincount as AdminCount, n.serviceprincipalnames as SPNs"] +["Users Computer Direct Admin Count","HTML","Users_Count_DirectAdminComputers.html","MATCH (u:User)-[:AdminTo]->(c:Computer) RETURN count(DISTINCT(c.name)) AS COMPUTER, u.name AS USER ORDER BY count(DISTINCT(c.name)) DESC"] +["Users Computer InDirect Admin Count","HTML","Users_Count_InDirectAdminComputers.html","MATCH (u:User)-[:AdminTo]->(c:Computer) RETURN count(DISTINCT(c.name)) AS COMPUTER, u.name AS USER ORDER BY count(DISTINCT(c.name)) DESC"] +["NeverActive Active Users","HTML","Users_NeverActive_Enabled.html","MATCH (n:User) WHERE n.lastlogontimestamp=-1.0 AND n.enabled=TRUE RETURN n.name as Username ORDER BY n.name"] +["Users GPOs Access Weirdness","HTML","Users_GPO_CheckACL.html","MATCH p=(u:User)-[r:AllExtendedRights|GenericAll|GenericWrite|Owns|WriteDacl|WriteOwner|GpLink*1..]->(g:GPO) RETURN p LIMIT 25"]"] +["Servers in OUs","HTML","ServersInOUs.html","MATCH (o:OU)-[:Contains]->(c:Computer) WHERE toUpper(c.operatingsystem) STARTS WITH "WINDOWS SERVER" RETURN o.name as OU,c.name as Computer,c.operatingsystem as OS"] +["Operating Systems Count","HTML","OperatingSystemCount.html","MATCH (c:Computer) RETURN c.operatingsystem aS OS, count(*) as Computers ORDER BY Computers DESC"] +["LAPS Deployment Count","HTML","LapsDeploymentCount.html","MATCH (c:Computer) RETURN c.haslaps as LAPSEnabled, count(*) as Computers ORDER BY Computers DESC"] +["LAPS Not Enabled","HTML","LapsNotEnabled.html","MATCH (c:Computer) WHERE c.haslaps=false RETURN c.haslaps as LAPSEnabled, c.name as Computer ORDER BY Computer"] +["Domain List","HTML","Domains.html","MATCH (n:Domain) return n.name as Domain, n.functionallevel as FunctionalLevel, n.highvalue as HighValue, n.domain as DNS"] +["Operating Systems Unsupported","HTML","OperatingSystemUnsupported.html","MATCH (c:Computer) WHERE c.operatingsystem =~ '.*(2000|2003|2008|xp|vista|7|me).*' RETURN c.name as Computer, c.operatingsystem as UnsupportedOS, c.enabled as Enabled"] +["GPOs","HTML","GPOs.html","Match (n:GPO) return n.name as GPO, n.highvalue as HighValue, n.gpcpath as Path"] +["HighValue Group Members","HTML","Groups-HighValue-members.html","MATCH p=(n:User)-[r:MemberOf*1..]->(m:Group {highvalue:true}) RETURN n.name as User, m.name as Group"] +["Add Use Delegation","HTML","User-AddToGroupDelegation.html","MATCH (n:User {admincount:False}) MATCH p=allShortestPaths((n)-[r:AddMember*1..]->(m:Group)) RETURN n.name as User, m.name as Group"]