Nessus report downloader + merger
| #!/usr/bin/env python3 | |
| ####################################################################################### | |
| # Name: Nessus Report Toolkit (download + merge) | |
| # | |
| # Combines and extends: | |
| # - nessus_report_downloader.py (by Nikhil Raj, nikhilraj149@gmail.com) | |
| # - nessus_merge_vibe.py | |
| # | |
| # Description: Download scan reports from a Nessus server, merge existing .nessus | |
| # files into a single combined report, or do both in one pass | |
| # (download an entire folder, then merge the results). | |
| # | |
| # Usage examples: | |
| # List scans on a server: | |
| # python nessus_toolkit.py -i 127.0.0.1 -u admin -p secret | |
| # | |
| # Download specific scan ids: | |
| # python nessus_toolkit.py -i 127.0.0.1 -u admin -p secret -s 12,15 | |
| # | |
| # Download specific scan ids AND merge them into one file: | |
| # python nessus_toolkit.py -i 127.0.0.1 -u admin -p secret -s 12,15 --merge \ | |
| # -o combined.nessus -r merged_scans | |
| # | |
| # Download every scan in folder(s) 3 and 7, then merge into one file: | |
| # python nessus_toolkit.py -i 127.0.0.1 -u admin -p secret --folder-id 3,7 \ | |
| # --download-folder-then-merge -o combined.nessus -r merged_scans | |
| # | |
| # Merge .nessus files already on disk (no server needed): | |
| # python nessus_toolkit.py --merge-only -d ./reports -o combined.nessus -r merged_scans | |
| # | |
| # Requirements: requests (required), prettytable (optional, nicer scan listing) | |
| ####################################################################################### | |
| import os | |
| import sys | |
| import json | |
| import time | |
| import getpass | |
| import argparse | |
| import xml.etree.ElementTree as ET | |
| from datetime import datetime | |
| import requests | |
| try: | |
| from prettytable import PrettyTable | |
| HAS_PRETTYTABLE = True | |
| except ImportError: | |
| HAS_PRETTYTABLE = False | |
| requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning) | |
| SLEEP_TIME = 1.0 | |
| # --------------------------------------------------------------------------------- # | |
| # HTTP helpers | |
| # --------------------------------------------------------------------------------- # | |
| def sendGetRequest(url, headers): | |
| try: | |
| return requests.get(url, verify=False, headers=headers) | |
| except requests.exceptions.ConnectionError: | |
| printMessage("Failed to establish connection", 0) | |
| sys.exit(1) | |
| def sendPostRequest(url, json_data=None, headers=None): | |
| try: | |
| return requests.post(url, verify=False, headers=headers or {}, json=json_data or {}) | |
| except requests.exceptions.ConnectionError: | |
| printMessage("Failed to establish connection", 0) | |
| sys.exit(1) | |
| def sendDeleteRequest(url, json_data=None, headers=None): | |
| try: | |
| return requests.delete(url, verify=False, headers=headers or {}, json=json_data or {}) | |
| except requests.exceptions.ConnectionError: | |
| printMessage("Failed to establish connection", 0) | |
| sys.exit(1) | |
| def printMessage(msg, flag=1): | |
| if flag == 1: | |
| print("[+] " + msg) | |
| elif flag == 0: | |
| print("[-] " + msg) | |
| elif flag == 2: | |
| print("[*] " + msg) | |
| else: | |
| print(msg) | |
| def checkStatus(resp, status_msg, error_msg): | |
| if resp.status_code == 200: | |
| printMessage(status_msg, 1) | |
| return True | |
| printMessage(error_msg, 0) | |
| return False | |
| # --------------------------------------------------------------------------------- # | |
| # Scan listing helpers | |
| # --------------------------------------------------------------------------------- # | |
| def printTable(data, table_headers): | |
| tab = PrettyTable(table_headers) | |
| for row in data: | |
| line = [] | |
| for header in table_headers: | |
| if "date" in header: | |
| line.append(datetime.fromtimestamp(int(row[header])).strftime('%Y-%m-%d %H:%M:%S')) | |
| else: | |
| line.append(str(row[header])) | |
| tab.add_row(line) | |
| print(tab) | |
| def printScanData(scan_data): | |
| folder_info = {folder["id"]: folder["name"] for folder in scan_data["folders"]} | |
| if HAS_PRETTYTABLE: | |
| printTable(scan_data["scans"], ["id", "name", "folder_id", "status", "creation_date", "last_modification_date"]) | |
| else: | |
| print('\t %-10s %-20s %-20s %-40s %-20s %-20s' % ( | |
| "Scan Id", "Folder Name (id)", "Scan status", "Scan Name", "creation_date", "last_modification_date")) | |
| print('\t %-10s %-20s %-20s %-40s %-20s %-20s' % ( | |
| "-------", "---------------", "------------", "-----------------", "-------------------", "--------------------")) | |
| for scan in scan_data["scans"]: | |
| print('\t %-10s %-20s %-20s %-40s %-20s %-20s' % ( | |
| str(scan["id"]), | |
| folder_info[scan["folder_id"]] + ' (' + str(scan["folder_id"]) + ') ', | |
| scan["status"], scan["name"], | |
| datetime.fromtimestamp(int(scan["creation_date"])).strftime('%Y-%m-%d %H:%M:%S'), | |
| datetime.fromtimestamp(int(scan["last_modification_date"])).strftime('%Y-%m-%d %H:%M:%S'))) | |
| print() | |
| def verifyScanId(scan_data, ui_scan_id): | |
| master_ids = [scan["id"] for scan in scan_data["scans"]] | |
| if ui_scan_id == "all": | |
| return master_ids | |
| valid = [] | |
| for scan in ui_scan_id.split(","): | |
| if int(scan) in master_ids: | |
| valid.append(scan) | |
| else: | |
| printMessage("Omitting invalid Scan ID: " + scan, 0) | |
| return valid | |
| def verifyFolderId(scan_data, ui_folder_id): | |
| master_folder_ids = [folder["id"] for folder in scan_data["folders"]] | |
| valid_folder_ids = [] | |
| for folder_id in ui_folder_id.split(","): | |
| if int(folder_id) in master_folder_ids: | |
| valid_folder_ids.append(folder_id) | |
| else: | |
| printMessage("Omitting invalid folder ID: " + folder_id, 0) | |
| scan_ids = [] | |
| for scan in scan_data["scans"]: | |
| for folder_id in valid_folder_ids: | |
| if int(scan["folder_id"]) == int(folder_id): | |
| scan_ids.append(scan["id"]) | |
| return scan_ids | |
| # --------------------------------------------------------------------------------- # | |
| # Download | |
| # --------------------------------------------------------------------------------- # | |
| def getFormatAndChapterList(nessus_format_list, chapter_list, db_pass): | |
| data = [] | |
| chapter_map = { | |
| "0": "vuln_hosts_summary", "1": "vuln_by_host", "2": "vuln_by_plugin", | |
| "3": "compliance_exec", "4": "compliance", "5": "remediations", | |
| } | |
| for nessus_format in nessus_format_list: | |
| if nessus_format == "0": | |
| data.append({'format': 'nessus', 'chapters': ''}) | |
| elif nessus_format in ("1", "2"): | |
| fmt = 'pdf' if nessus_format == "1" else 'html' | |
| for chapter in chapter_list: | |
| if chapter in chapter_map: | |
| data.append({'format': fmt, 'chapters': chapter_map[chapter]}) | |
| elif nessus_format == "3": | |
| data.append({'format': 'csv', 'chapters': ''}) | |
| elif nessus_format == "4": | |
| data.append({'format': 'db', 'chapters': '', 'password': db_pass}) | |
| return data | |
| def downloadNessusReport(base_url, token, scan_id_list, json_user_data, download_dir): | |
| """Downloads one format/chapter combo for every scan id and returns the list of | |
| file paths that were actually written to disk.""" | |
| saved_files = [] | |
| os.makedirs(download_dir, exist_ok=True) | |
| token_header = {'X-Cookie': 'token=' + token['token']} | |
| for scan_id in scan_id_list: | |
| printMessage("Format: {0} | Chapter: {1}".format(json_user_data["format"], json_user_data.get("chapters", ""))) | |
| printMessage("Initiating download request for scan id: " + str(scan_id), 1) | |
| url = base_url + "/scans/{0}/export".format(scan_id) | |
| resp = sendPostRequest(url, json_data=json_user_data, headers=token_header) | |
| file_token = json.loads(resp.text) | |
| url = base_url + "/scans/{0}/export/{1}/status".format(scan_id, file_token["file"]) | |
| resp2 = sendGetRequest(url, headers=token_header) | |
| while json.loads(resp2.text)["status"] == "loading": | |
| printMessage("Report is not ready yet, waiting {0}s".format(SLEEP_TIME), 0) | |
| time.sleep(SLEEP_TIME) | |
| resp2 = sendGetRequest(url, headers=token_header) | |
| if json.loads(resp2.text)["status"] != "ready": | |
| printMessage("Report never became ready for scan id: " + str(scan_id), 0) | |
| continue | |
| url = base_url + "/tokens/{0}/download".format(file_token["token"]) | |
| resp3 = sendGetRequest(url, headers=token_header) | |
| if not checkStatus(resp3, "Started downloading the nessus report", "Unable to download scan: " + str(scan_id)): | |
| continue | |
| filename = resp3.headers["Content-Disposition"].split('"')[1] | |
| filepath = os.path.join(download_dir, filename) | |
| try: | |
| with open(filepath, "w") as fh: | |
| fh.write(resp3.text) | |
| except (IOError, UnicodeEncodeError): | |
| base, ext = os.path.splitext(filename) | |
| chapters = json_user_data.get("chapters", "") | |
| filename2 = f"{base}_{chapters}{ext}" if chapters else filename | |
| filepath = os.path.join(download_dir, filename2) | |
| with open(filepath, "wb") as fh: | |
| fh.write(resp3.content) | |
| printMessage("Report was saved in " + filepath, 1) | |
| printMessage("", 99) | |
| saved_files.append(filepath) | |
| return saved_files | |
| # --------------------------------------------------------------------------------- # | |
| # Merge | |
| # --------------------------------------------------------------------------------- # | |
| def get_nessus_files(directory): | |
| if not os.path.isdir(directory): | |
| return [] | |
| return sorted( | |
| os.path.join(directory, f) | |
| for f in os.listdir(directory) | |
| if f.lower().endswith(".nessus") | |
| ) | |
| def merge_nessus_files(files, output_file, report_name="merged_scans"): | |
| """Merges every (etc.) from each input file's into a | |
| single combined element, rather than just stacking | |
| multiple tags side by side under the root.""" | |
| if not files: | |
| printMessage("No .nessus files to merge", 0) | |
| return None | |
| combined_root = ET.Element("NessusClientData_v2") | |
| report_elem = ET.SubElement(combined_root, "Report") | |
| report_elem.set("name", report_name) | |
| policy_included = False | |
| for file_path in files: | |
| try: | |
| xml_root = ET.parse(file_path).getroot() | |
| # Keep one block (from the first file that has one) so the | |
| # combined file stays structurally valid. | |
| if not policy_included: | |
| policy = xml_root.find("Policy") | |
| if policy is not None: | |
| combined_root.insert(0, policy) | |
| policy_included = True | |
| for report in xml_root.findall(".//Report"): | |
| for child in list(report): | |
| report_elem.append(child) | |
| printMessage("Merged: " + os.path.basename(file_path), 1) | |
| except ET.ParseError as e: | |
| printMessage(f"Skipping invalid file: {file_path} ({e})", 0) | |
| output_dir = os.path.dirname(os.path.abspath(output_file)) | |
| os.makedirs(output_dir, exist_ok=True) | |
| ET.ElementTree(combined_root).write(output_file, encoding="utf-8", xml_declaration=True) | |
| printMessage(f'Combined report (name="{report_name}") written to: {output_file}', 1) | |
| return output_file | |
| # --------------------------------------------------------------------------------- # | |
| # CLI | |
| # --------------------------------------------------------------------------------- # | |
| def build_arg_parser(): | |
| parser = argparse.ArgumentParser( | |
| prog="nessus_toolkit.py", | |
| description="Download Nessus scan reports and/or merge .nessus files into a single combined report.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog="""examples: | |
| list scans on a server (prompts for password, hidden input) | |
| %(prog)s -i 127.0.0.1 -u admin | |
| same, with the password passed on the command line instead | |
| %(prog)s -i 127.0.0.1 -u admin -p secret | |
| download specific scan ids | |
| %(prog)s -i 127.0.0.1 -u admin -p secret -s 12,15 | |
| download specific scan ids, saving to a folder, AND merge them into one file | |
| %(prog)s -i 127.0.0.1 -u admin -p secret -s 12,15 -O ./raw -m -o combined.nessus -r merged_scans | |
| download every scan in folder(s), then merge into one file | |
| %(prog)s -i 127.0.0.1 -u admin -p secret -F 3,7 -x -o combined.nessus -r merged_scans | |
| merge .nessus files already on disk (no server needed) | |
| %(prog)s -M -d ./reports -o combined.nessus -r merged_scans | |
| every long option also has a short form (see below); -x is the same as | |
| --download-folder-then-merge / --downloader-folder-then-merge. | |
| """ | |
| ) | |
| conn = parser.add_argument_group("server connection (required unless --merge-only)") | |
| conn.add_argument("-i", "--server", help="IP[:PORT] of nessus server") | |
| conn.add_argument("-u", "--user", help="nessus username") | |
| conn.add_argument("-p", "--passwd", | |
| help="nessus password. If omitted, you'll be prompted for it securely " | |
| "(input is hidden, nothing is echoed to the screen)") | |
| dl = parser.add_argument_group("download options") | |
| dl.add_argument("-s", "--scan-id", help="comma separated scan id(s), or 'all'") | |
| dl.add_argument("-F", "--folder-id", help="comma separated nessus folder id(s)") | |
| dl.add_argument("-f", "--format", default="0", | |
| help="comma separated report format(s): 0-nessus (default), 1-pdf, 2-html, 3-csv, 4-nessus-db") | |
| dl.add_argument("-c", "--chapter", default="1", | |
| help="comma separated chapters: 0-vuln_hosts_summary, 1-vuln_by_host (default), " | |
| "2-vuln_by_plugin, 3-compliance_exec, 4-compliance, 5-remediations") | |
| dl.add_argument("-b", "--db-pass", default="nessus", help="password for nessus-db export (default: nessus)") | |
| dl.add_argument("-O", "--download-dir", default=".", help="directory to save downloaded reports into (default: current directory)") | |
| mg = parser.add_argument_group("merge options") | |
| mg.add_argument("-m", "--merge", action="store_true", | |
| help="merge the reports just downloaded into a single .nessus file") | |
| mg.add_argument("-M", "--merge-only", action="store_true", | |
| help="skip downloading; just merge existing .nessus files found in --merge-dir") | |
| mg.add_argument("-x", "--download-folder-then-merge", "--downloader-folder-then-merge", | |
| dest="download_folder_then_merge", action="store_true", | |
| help="shortcut: download every scan in --folder-id, then merge the results (implies --merge)") | |
| mg.add_argument("-d", "--merge-dir", | |
| help="directory of .nessus files to merge. For --merge-only this is the source directory " | |
| "(default: current directory). When merging right after a download, set this to merge " | |
| "every .nessus file found in that directory instead of just the ones just downloaded " | |
| "(default: whatever --download-dir was)") | |
| mg.add_argument("-o", "--output", default="combined_results.nessus", help="path of the merged output .nessus file") | |
| mg.add_argument("-r", "--report-name", default="merged_scans", | |
| help='name="" attribute to set on the merged element (default: merged_scans)') | |
| return parser | |
| def nessus_login(base_url, user, passwd): | |
| resp = sendGetRequest(base_url, {}) | |
| if not checkStatus(resp, "Connected to nessus server", "Unable to connect to server at " + base_url): | |
| sys.exit(1) | |
| resp = sendPostRequest(base_url + "/session", {'username': user, 'password': passwd}) | |
| if not checkStatus(resp, "Login successful", "Invalid login credentials"): | |
| sys.exit(1) | |
| return json.loads(resp.text) | |
| def nessus_logout(base_url, token): | |
| resp = sendDeleteRequest(base_url + "/session", headers={'X-Cookie': 'token=' + token['token']}) | |
| checkStatus(resp, "Successfully logged out user session", "Unable to logout the current active session") | |
| def do_merge_only(args): | |
| merge_dir = args.merge_dir or os.getcwd() | |
| files = get_nessus_files(merge_dir) | |
| if not files: | |
| printMessage("No .nessus files found in: " + merge_dir, 0) | |
| sys.exit(1) | |
| merge_nessus_files(files, args.output, args.report_name) | |
| def do_download(args): | |
| if not (args.server and args.user): | |
| printMessage("--server/-i and --user/-u are required unless --merge-only is used", 0) | |
| sys.exit(1) | |
| if not args.passwd: | |
| try: | |
| args.passwd = getpass.getpass("Enter password: ") | |
| except EOFError: | |
| printMessage("No password entered", 0) | |
| sys.exit(1) | |
| if not args.passwd: | |
| printMessage("Password cannot be empty", 0) | |
| sys.exit(1) | |
| if args.download_folder_then_merge and not args.folder_id: | |
| printMessage("--download-folder-then-merge requires --folder-id", 0) | |
| sys.exit(1) | |
| merge_after = args.merge or args.download_folder_then_merge | |
| if ":" in args.server: | |
| ip, port = args.server.split(":", 1) | |
| else: | |
| ip, port = args.server, "8834" | |
| base_url = "https://" + ip + ":" + port | |
| format_arg = args.format | |
| if merge_after and "0" not in format_arg.split(","): | |
| printMessage("Merging requires the 'nessus' (XML) export format; overriding --format to 0", 2) | |
| format_arg = "0" | |
| token = nessus_login(base_url, args.user, args.passwd) | |
| token_header = {'X-Cookie': 'token=' + token['token']} | |
| resp = sendGetRequest(base_url + "/scans", headers=token_header) | |
| if not checkStatus(resp, "Fetching scan reports\n", "Unable to fetch nessus scans"): | |
| nessus_logout(base_url, token) | |
| sys.exit(1) | |
| scan_data = json.loads(resp.text) | |
| if not args.scan_id and not args.folder_id: | |
| printScanData(scan_data) | |
| nessus_logout(base_url, token) | |
| return | |
| if args.scan_id: | |
| scan_id_list = verifyScanId(scan_data, args.scan_id) | |
| else: | |
| scan_id_list = verifyFolderId(scan_data, args.folder_id) | |
| printMessage("Identified " + str(len(scan_id_list)) + " scan(s) for download\n", 2) | |
| downloaded_files = [] | |
| format_specification = getFormatAndChapterList(format_arg.split(","), args.chapter.split(","), args.db_pass) | |
| for report_format in format_specification: | |
| downloaded_files += downloadNessusReport(base_url, token, scan_id_list, report_format, args.download_dir) | |
| nessus_logout(base_url, token) | |
| if merge_after: | |
| if args.merge_dir: | |
| files_to_merge = get_nessus_files(args.merge_dir) | |
| else: | |
| files_to_merge = [f for f in downloaded_files if f.lower().endswith(".nessus")] | |
| merge_nessus_files(files_to_merge, args.output, args.report_name) | |
| def main(): | |
| parser = build_arg_parser() | |
| if len(sys.argv) == 1: | |
| parser.print_help() | |
| sys.exit(1) | |
| args = parser.parse_args() | |
| if args.merge_only: | |
| do_merge_only(args) | |
| else: | |
| do_download(args) | |
| printMessage("✅ All done, here is your mandatory vibe coded emoji.") | |
| if __name__ == '__main__': | |
| main() |
评论
?
参与讨论