From ba372b729bf9482c8f64d017bbf8f65fe6e9c025 Mon Sep 17 00:00:00 2001 From: Dejan Peretin Date: Thu, 28 Jun 2012 12:57:34 +0200 Subject: [PATCH] Initial commit --- LICENSE.txt | 210 +++++++++++++++++++++++ NessusXMLRPC.py | 324 ++++++++++++++++++++++++++++++++++++ nessus.conf | 24 +++ nessus.py | 435 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 993 insertions(+) create mode 100755 LICENSE.txt create mode 100755 NessusXMLRPC.py create mode 100644 nessus.conf create mode 100755 nessus.py diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100755 index 0000000..1d726d0 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,210 @@ +Certain components of this software are provided or made available only +subject to the licenses under which such components were licensed to +HomeAway. The relevant components and corresponding licenses are listed +[in the folder in the distribution titled 'licenses']. In any event, the +disclaimer of warranty and limitation of liability provision in this +Agreement will apply to all Software in this distribution. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/NessusXMLRPC.py b/NessusXMLRPC.py new file mode 100755 index 0000000..605f6f0 --- /dev/null +++ b/NessusXMLRPC.py @@ -0,0 +1,324 @@ +#!/usr/bin/python + +""" +Copyright (c) 2010 HomeAway, Inc. +All rights reserved. http://www.homeaway.com + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import sys +import xml.etree.ElementTree + +from httplib import HTTPSConnection,CannotSendRequest,ImproperConnectionState +from urllib import urlencode +from random import randint +from time import sleep + +from exceptions import Exception + +# Arbitary minimum and maximum values for random sequence num +SEQMIN = 10000 +SEQMAX = 99999 + +# Simple exceptions for error handling +class NessusError(Exception): + """ + Base exception. + """ + def __init__( self, info, contents ): + self.info = info + self.contents = contents + +class RequestError(NessusError): + """ + General requests. + """ + pass + +class LoginError(NessusError): + """ + Login. + """ + pass + +class PolicyError(NessusError): + """ + Policies. + """ + pass + +class ScanError(NessusError): + """ + Scans. + """ + pass + +class ReportError(NessusError): + """ + Reports. + """ + pass + +class ParseError(NessusError): + """ + Parsing XML. + """ + pass + +class Scanner: + def __init__( self, host, port, login=None, password=None): + """ + Initialize the scanner instance by setting up a connection and authenticating + if credentials are provided. + + @type host: string + @param host: The hostname of the running Nessus server. + @type port: number + @param port: The port number for the XMLRPC interface on the Nessus server. + @type login: string + @param login: The username for logging in to Nessus. + @type password: string + @param password: The password for logging in to Nessus. + """ + self.host = host + self.port = port + self.connection = self._connect( host, port ) + self.headers = {"Content-type":"application/x-www-form-urlencoded","Accept":"text/plain"} + + if login != None and password != None: + self.login( login, password ) + + def _connect( self, host, port ): + """ + Internal method for connecting to the target Nessus server. + + @type host: string + @param host: The hostname of the running Nessus server. + @type port: number + @param port: The port number for the XMLRPC interface on the Nessus server. + """ + self.connection = HTTPSConnection( host, port ) + + def _request( self, method, target, params ): + """ + Internal method for submitting requests to the target Nessus server, rebuilding + the connection if needed. + + @type method: string + @param method: The HTTP verb/method used in the request (almost always POST). + @type target: string + @param target: The target path (or function) of the request. + @type params: string + @param params: The URL encoded parameters used in the request. + """ + try: + if self.connection is None: + self._connect( self.host, self.port ) + self.connection.request( method, target, params, self.headers ) + except CannotSendRequest,ImproperConnectionState: + self._connect( self.host, self.port) + self.login( self.login, self.password ) + self._request( method, target, params, self.headers ) + return self.connection.getresponse().read() + + def _rparse( self, parsed ): + """ + Recursively parse XML and generate an interable hybrid dictionary/list with all data. + + @type parsed: xml.etree.ElementTree.Element + @param parsed: An ElementTree Element object of the parsed XML. + """ + result = dict() + # Iterate over each element + for element in parsed.getchildren(): + # If the element has children, use a dictionary + children = element.getchildren() + if len(children) > 0: + # We have children for this element + if type(result) is list: + # Append the next parse, we're apparently in a list() + result.append(self._rparse( element )) + elif type(result) is dict and result.has_key(element.tag): + # Change the dict() to a list() if we have multiple hits + tmp = result + result = list() + # Iterate through the values in the dictionary, adding values only + # - This reduces redundancy in parsed output (no outer tags) + for val in tmp.itervalues(): + result.append(val) + else: + result[element.tag] = dict() + result[element.tag] = self._rparse( element ) + else: + result[element.tag] = element.text + return result + + def parse( self, response ): + """ + Parse the XML response from the server. + + @type response: string + @param response: Response XML from the server following a request. + """ + # Okay, for some reason there's a bug with how expat handles newlines + try: + return self._rparse( xml.etree.ElementTree.fromstring(response.replace("\n","")) ) + except Exception: + raise ParseError( "Error parsing XML", response ) + + def login( self, login, password, seq=randint(SEQMIN,SEQMAX) ): + """ + Log in to the Nessus server and preserve the token value for subsequent requests. + + @type login: string + @param login: The username for logging in to Nessus. + @type password: string + @param password: The password for logging in to Nessus. + @type seq: number + @param seq: A sequence number that will be echoed back for unique identification (optional). + """ + self.username = login + self.password = password + + params = urlencode({ 'login':self.username, 'password':self.password, 'seq':seq}) + response = self._request( "POST", "/login", params ) + parsed = self.parse( response ) + + if parsed['status'] == "OK": + contents = parsed['contents'] + self.token = contents['token'] # Actual token value + user = contents['user'] # User dict (admin status, user name) + self.isadmin = user['admin'] # Is the logged in user an admin? + + self.headers["Cookie"] = "token=%s" % self.token # Persist token value for subsequent requests + else: + raise LoginError( "Unable to login", contents ) + + def logout( self, seq=randint(SEQMIN,SEQMAX) ): + """ + Log out of the Nessus server, invalidating the current token value. Returns True if successful, False if not. + + @type seq: number + @param seq: A sequence number that will be echoed back for unique identification (optional). + """ + params = urlencode( {'seq':seq} ) + response = self._request( "POST", "/logout", params) + parsed = self.parse( response ) + + if parsed['status'] == "OK" and parsed['contents'] == "OK": + return True + else: + return False + + def policyList( self, seq=randint(SEQMIN,SEQMAX) ): + """ + List the current policies configured on the server and return a dict with the info. + + @type seq: number + @param seq: A sequence number that will be echoed back for unique identification (optional). + """ + params = urlencode( {'seq':seq} ) + response = self._request( "POST", "/policy/list", params) + parsed = self.parse( response ) + + if parsed['status'] == "OK": + contents = parsed['contents'] + policies = contents['policies'] # Should be an iterable list of policies + else: + raise PolicyError( "Unable to get policy list", contents ) + return policies + + def scanNew( self, scan_name, target, policy_id, seq=randint(SEQMIN,SEQMAX)): + """ + Start up a new scan on the Nessus server immediately. + + @type scan_name: string + @param scan_name: The desired name of the scan. + @type target: string + @param target: A Nessus-compatible target string (comma separation, CIDR notation, etc.) + @type policy_id: number + @param policy_id: The unique ID of the policy to be used in the scan. + @type seq: number + @param seq: A sequence number that will be echoed back for unique identification (optional). + """ + params = urlencode( {'target':target,'policy_id':policy_id,'scan_name':scan_name,'seq':seq} ) + response = self._request( "POST", "/scan/new", params) + parsed = self.parse( response ) + + if parsed['status'] == "OK": + contents = parsed['contents'] + return contents['scan'] # Return what you can about the scan + else: + raise ScanError("Unable to start scan", contents ) + + def quickScan( self, scan_name, target, policy_name, seq=randint(SEQMIN,SEQMAX)): + """ + Configure a new scan using a canonical name for the policy. Perform a lookup for the policy ID and configure the scan, + starting it immediately. + + @type scan_name: string + @param scan_name: The desired name of the scan. + @type target: string + @param target: A Nessus-compatible target string (comma separation, CIDR notation, etc.) + @type policy_name: string + @param policy_name: The name of the policy to be used in the scan. + @type seq: number + @param seq: A sequence number that will be echoed back for unique identification (optional). + """ + policies = self.policyList() + if type(policies['policy']) is dict: + # There appears to be only one configured policy + policy = policies['policy'] + if policy['policyName'] == policy_name: + policy_id = policy['policyID'] + else: + raise PolicyError( "Unable to parse policies from policyList()", (scan_name,target,policy_name)) + else: + # We have multiple policies configured + for policy in policies: + if policy['policyName'] == policy_name: + policy_id = policy['policyID'] + return self.scanNew( scan_name, target, policy_id ) + + def reportList( self, seq=randint(SEQMIN,SEQMAX)): + """ + Generate a list of reports available on the Nessus server. + + @type seq: number + @param seq: A sequence number that will be echoed back for unique identification (optional). + """ + params = urlencode({'seq':seq}) + response = self._request( "POST", "/report/list", params) + parsed = self.parse( response ) + + if parsed['status'] == "OK": + contents = parsed['contents'] + return contents['reports'] # Return an iterable list of reports + else: + raise ReportError( "Unable to get reports.", contents ) + + def reportDownload( self, report, version="v2" ): + """ + Download a report (XML) for a completed scan. + + @type report: string + @param report: The UUID of the report or completed scan. + @type version: string + @param version: The version of the .nessus XML file you wish to download. + """ + if version == "v1": + params = urlencode({'report':report, 'v1':version }) + else: + params = urlencode({'report':report}) + return self._request( "POST", "/file/report/download", params ) diff --git a/nessus.conf b/nessus.conf new file mode 100644 index 0000000..368dfc8 --- /dev/null +++ b/nessus.conf @@ -0,0 +1,24 @@ +# Defaults + +[core] +server = nessus01 +port = 8834 +user = nessus +password = *pass* +logfile = /home/user/tools/nessus-xmlrpc/nessus.log +loglevel = debug +limit = 3 +sleepmax = 600 +sleepmin = 300 + +[smtp] +to = me@mydomain.com +from = security@mydomain.com +server = mysmtpserver +port = 25 + +[report] +outputdir = /home/user/tools/nessus-xmlrpc/reports +xsltproc = /usr/bin/xsltproc +xsltlog = /home/user/tools/nessus-xmlrpc/reports/xsltproc.log +xsl = /home/user/tools/nessus-xmlrpc/reports/html.xsl diff --git a/nessus.py b/nessus.py new file mode 100755 index 0000000..0683396 --- /dev/null +++ b/nessus.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python +""" +Copyright (c) 2010 HomeAway, Inc. +All rights reserved. http://www.homeaway.com + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import sys,subprocess,shlex,os,smtplib,logging,socket,zipfile +import xml.etree.ElementTree +import ConfigParser + +from NessusXMLRPC import Scanner,ParseError +from optparse import OptionParser +from random import randint +from time import sleep +from logging.handlers import WatchedFileHandler +from datetime import datetime + +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email.mime.text import MIMEText +from email import Encoders + +from exceptions import KeyError + +class Nessus: + def __init__( self, configfile, scans ): + """ + @type configfile: string + @param configfile: Full path to a configuration file for loading defaults + @type scans: list + @param scans: A list() of scans assembled with all necessary context + """ + self.logformat = "%s %8s %s" + self.scans_running = [] # Scans currently running. + self.scans_complete = [] # Scans that have completed. + self.scans = scans # Scans that remain to be started. + + self.started = False # Flag for telling when scanning has started. + + # Parse the configuration file to set everything up + self.config = ConfigParser.ConfigParser() + self.config.readfp(open(configfile)) + + loglevels = { 'debug' : logging.DEBUG, + 'info' : logging.INFO, + 'warning' : logging.WARNING, + 'error' : logging.ERROR, + 'critical': logging.CRITICAL } + # Core settings + self.logfile = self.config.get( 'core', 'logfile' ) + self.loglevel = loglevels[self.config.get( 'core', 'loglevel' )] + + # Setup some basic logging. + self.logger = logging.getLogger('Nessus') + self.logger.setLevel(self.loglevel) + self.loghandler = WatchedFileHandler( self.logfile ) + self.logger.addHandler(self.loghandler) + + self.debug( "CONF configfile = %s" % configfile ) + self.debug( "Logger initiated; Logfile: %s, Loglevel: %s" % (self.logfile,self.loglevel)) + + self.server = self.config.get( 'core', 'server' ) + self.debug( "CONF core.server = %s" % self.server) + self.port = self.config.getint( 'core', 'port' ) + self.debug( "CONF core.port = %s" % self.port) + self.user = self.config.get( 'core', 'user' ) + self.debug( "CONF core.user = %s" % self.user ) + self.password = self.config.get( 'core', 'password' ) + self.debug( "CONF core.password set" ) + self.limit = self.config.getint( 'core', 'limit' ) + self.debug( "CONF core.limit = %d" % self.limit ) + self.sleepmax = self.config.getint( 'core', 'sleepmax') + self.debug( "CONF core.sleepmax = %d" % self.sleepmax ) + self.sleepmin = self.config.getint( 'core', 'sleepmin') + self.debug( "CONF core.sleepmin = %d" % self.sleepmin ) + + # SMTP settings + self.emailto = self.config.get( 'smtp', 'to' ) + self.debug( "CONF smtp.emailto = %s" % self.emailto ) + self.emailfrom = self.config.get( 'smtp', 'from' ) + self.debug( "CONF smtp.emailfrom = %s" % self.emailfrom ) + self.smtpserver = self.config.get( 'smtp', 'server' ) + self.debug( "CONF smtp.smtpserver = %s" % self.smtpserver ) + self.smtpport = self.config.getint( 'smtp', 'port' ) + self.debug( "CONF smtp.smtpport = %d" % self.smtpport ) + + # Reporting settings + self.reports = self.config.get( 'report', 'outputdir' ) + self.debug( "CONF report.reports = %s" % self.reports ) + self.xsltproc = self.config.get( 'report', 'xsltproc' ) + self.debug( "CONF report.xsltproc = %s" % self.xsltproc ) + self.xsltlog = self.config.get( 'report', 'xsltlog' ) + self.debug( "CONF report.xsltlog = %s" % self.xsltlog ) + self.xsl = self.config.get( 'report', 'xsl' ) + self.debug( "CONF report.xsl = %s" % self.xsl ) + + self.debug( "PARSED scans: %s" % self.scans ) + + try: + self.info("Nessus scanner started.") + self.scanner = Scanner( self.server, self.port, self.user, self.password ) + self.info("Connected to Nessus server; authenticated to server '%s' as user '%s'" % (self.server,self.user)) + except socket.error as (errno,strerror): + self.error("Socket error encountered while connecting to Nessus server: %s. User: '%s', Server: '%s', Port: %s" % (strerror,self.user,self.server,self.port)) + return None + + def start( self ): + """ + Proxy for resume() really. Basically begins scanning with the current scanning list. + """ + self.started = True + + if len(self.scans) > 1: + self.info("Starting with multiple scans") + else: + self.info("Starting with a single scan") + + if self.scans_running is None: + self.scans_running = [] + + return self.resume() + + def stop( self ): + """ + We have a start() so we most certainly should have a stop(). This should prevent scans from being continued. + """ + self.started = False + + def resume( self ): + """ + Basically gets scans going, observing the limit. + """ + if self.started and len(self.scans) > 0 and len(self.scans_running) < self.limit: + count = len(self.scans_running) + for scan in self.scans: + if self._startscan(scan): + count += 1 + if count == self.limit: + self.warning("Concurrent scan limit reached (currently set at %d)" % self.limit) + self.warning("Will monitor scans and continue as possible") + break + return self.scans_running + + def _startscan( self, scan ): + """ + Start a specific scan in the scans list. + """ + currentscan = self.scanner.quickScan( scan['name'], scan['target'], scan['policy'] ) + if currentscan is not None: + self.info("Scan successfully started; Owner: '%s', Name: '%s'" % (currentscan['owner'],currentscan['scan_name'])) + else: + self.error("Unable to start scan. Name: '%s', Target: '%s', Policy: '%s'" % (scan['name'],scan['target'],scan['policy'])) + return False + + # Add the newly started scan to the running least, remove it from the remaining + self.scans_running.append(currentscan) + self.scans.remove(scan) + return True + + def iscomplete( self ): + """ + Check for the completion of of running scans. Also, if there are scans left to be run, resume and run them. + """ + try: + reports = self.scanner.reportList() + except socket.error as (errno,strerror): + self.error("Socket error; %s" % strerror) + self.error("Invalidating connection and sleeping before we continue") + self.scanner.connection.close() + self.scanner.connection = None + sleep(self.sleepmax) + except ParseError as e: + self.error("%s; %s" % (e.info,e.contents)) + self.error("Continuing...") + return False + for scan in self.scans_running: + try: + if type(reports) is dict: + # We have only one report + report = reports['report'] + if report['status'] == 'completed' and scan['uuid'] == report['name']: + self.scans_complete.append(scan) + self.scans_running.remove(scan) + elif type(reports) is list: + # We have multiple reports to look through + for report in reports: + if report['status'] == 'completed' and scan['uuid'] == report['name']: + self.scans_complete.append(scan) + self.scans_running.remove(scan) + except KeyError: + self.error("KeyError when parsing XML from reportList(); continuing") + return False + + # Check to see if we're running under the limit and we have scans remaining. + # If so, run more scans up to the limit and continue. + + if len(self.scans_running)0 and self.started: + self.info("We can run more scans, resuming") + self.resume() + + elif len(self.scans_running)>0: + return False + else: + return True + + def report( self ): + """ + Report on currently completed scans. + """ + for scan in self.scans_complete: + pname = scan['scan_name'].replace(' ','') + + data = self.scanner.reportDownload( scan['uuid'] ) + xmlf = os.path.join( self.reports, pname+'.xml' ) + htmlf = os.path.join( self.reports, pname+'.html') + zipf = os.path.join( self.reports, pname+'.zip' ) + + self.genreport( data, xmlf, htmlf, zipf ) + self.info("XML report saved as '%s'" % xmlf) + self.info("HTML report saved as '%s'" % htmlf) + + # Put together the text of the email with the report attached + self.send_report( "Report: %s" % scan['scan_name'], self.gensummary(data), zipf) + self.info("Email report sent to '%s' from '%s' including '%s'" % ( self.emailto,self.emailfrom,zipf)) + + def genreport( self, data, xmlf, htmlf, zipf ): + """ + Simple method for transforming the XML spit out by the server into report-style HTML using + what's available. + + @type data: string + @param data: XML output from the report of a scan. + @type xmlf: string + @param xmlf: The file where the XML is to be output. + @type htmlf: string + @param htmlf: The file where the HTML is to be output. + @type zipf: string + @param zipf: The output ZipFile containing the compressed report. + """ + output = open( xmlf, "w") + output.write(data) + output.close() + + xsltlog = open( self.xsltlog, 'w' ) + # Transform the XML using the XSL provided by Nessus for HTML reports (quietly) + subprocess.call(shlex.split("%s %s %s -o %s" % (self.xsltproc,self.xsl,xmlf,htmlf)), stdout=xsltlog, stderr=xsltlog) + zip = zipfile.ZipFile( zipf, 'w' ) + zip.write(htmlf,arcname=os.path.basename(htmlf)) + zip.close() + xsltlog.close() + + def gensummary( self, data ): + """ + Generate a simple summary as the contents of the email report to be sent. + + @type data: string + @param data: XML data from the current report. + """ + severity = { '0' : 0, + '1' : 0, + '2' : 0, + '3' : 0 } + prefs = {} + pref = None + count = 0 + + parsed = xml.etree.ElementTree.fromstring(data) + + # Pull out the report name + report = parsed.find("Report").attrib['name'] + + # Pull out the name of the policy used + policy = parsed.find("Policy/policyName").text + + # Parse preferences and construct a dict from all settings + for preference in parsed.find("Policy/Preferences").getiterator("preference"): + for child in preference.getchildren(): + if child.tag == 'name': + prefs[child.text] = None + pref = child.text + elif child.tag == 'value': + prefs[pref] = child.text + # Parse severity for totals + for host in parsed.find("Report").getiterator("ReportHost"): + for item in host.getiterator("ReportItem"): + severity[item.attrib['severity']] += 1 + + return "Scan Name: %25s\nTarget(s): %25s\nPolicy: %28s\n\nRisk Summary\n%s\n%15s %3s\n%15s %3s\n%15s %3s\n\n%15s %3s" % ( report, prefs['TARGET'], policy,'-'*36,'High', severity['3'], 'Medium', severity['2'], 'Low', severity['1'], 'Open Ports', severity['0']) + + def send_report( self, subject, body, attachment, apptype='x/zip'): + """ + Send the email report to its destination. + + @type to: string + @param to: Destination email address for the report. + @type subject: string + @param subject: The subject of the email message. + @type body: string + @param body: The body of the email message (includes report summary). + @type attachment: string + @param attachment: Path to report file for attaching to message. + @type apptype: string + @param apptype: Application MIME type for attachment. + """ + message = MIMEMultipart() + message['From'] = self.emailfrom + message['To'] = self.emailto + message['Subject'] = subject + + message.attach( MIMEText( body )) + part = MIMEBase('application',apptype) + part.set_payload( open( attachment, 'r').read()) + Encoders.encode_base64(part) + part.add_header('Content-Disposition','attachment; filename="%s"' % os.path.basename(attachment)) + message.attach(part) + + conn = smtplib.SMTP(self.smtpserver, self.smtpport) + conn.sendmail( message['From'], self.emailto, message.as_string()) + conn.close() + + def close( self ): + """ + End it. + """ + return self.scanner.logout() + + def debug( self, msg ): + """ + @type msg: string + @param msg: Debug message to be written to the log. + """ + self.logger.debug( self.logformat % (datetime.now(),'DEBUG',msg)) + + def info( self, msg ): + """ + @type msg: string + @param msg: Info message to be written to the log. + """ + self.logger.info( self.logformat % (datetime.now(),'INFO',msg)) + + def warning( self, msg ): + """ + @type msg: string + @param msg: Warning message to be written to the log. + """ + self.logger.warning( self.logformat % (datetime.now(),'WARNING',msg)) + + def error( self, msg ): + """ + @type msg: string + @param msg: Error message to be written to the log. + """ + self.logger.info( self.logformat % (datetime.now(),'ERROR',msg)) + + def critical( self, msg ): + """ + @type msg: string + @param msg: Critical message to be written to the log. + """ + self.logger.critical( self.logformat % (datetime.now(),'CRITICAL',msg)) + + +############################################################################################################# + +if __name__ == "__main__": + """ + The goal with this tool is to essentially replace the command-line versions of the Nessus scanner. I + found with the latest version that they've deprecated version 1 of the Nessus XML output preventing + policies exported directly through the web interface from being used with the command-line versions to + automate scans. This tool is an example using the NessusXMLRPC module I've also written to completely + automate scans using the Nessus server. For more info, review the help/usage information available. + + nessus.py and NessusXMLRPC were written under Python v2.6.5 with xsltproc available in the PATH. Feel + free to tweak the default concurrent scanning limit in configuration file; what's currently set is + what worked the best on my test box. The Nessus daemon appears to be touchy when it comes to resources. + """ + parser = OptionParser() + parser.add_option("-t", dest='target', help="target string for Nessus scan") + parser.add_option("-n", dest='name', default="No-name Auto Scan", help="name for the scan") + parser.add_option("-p", dest='policy', help="policy (on server-side) to use in the scan") + parser.add_option("-f", dest='infile', help="input file with multiple scans to run") + parser.add_option("-c", dest='configfile', default='nessus.conf', help="configuration file to use") + + (options,args) = parser.parse_args() + + if options.configfile is not None and \ + (options.infile is not None or options.target is not None): + + if options.infile is not None and options.target is None: + # Start with multiple scans. + scans = [] + f = open(options.infile, "r") + for line in f: + scan = line.strip().split(',') + scans.append({'name':scan[0],'target':scan[1],'policy':scan[2]}) + x = Nessus( options.configfile, scans ) + scans = x.start() + elif options.target is not None and options.infile is None: + # Start with a single scan. + if options.name is not None and \ + options.target is not None and \ + options.policy is not None: + scan = [{ 'name' : options.name, 'target' : options.target, 'policy' : options.policy }] + x = Nessus( options.configfile, scan ) + scans = x.start() + else: + print "HARD ERROR: Incorrect usage.\n" + parser.print_help() + sys.exit(1) + while True: + if scans is None: + break + sleeptime = randint(x.sleepmin,x.sleepmax) + x.info("Sleeping for %d seconds, polling for scan completion" % sleeptime) + sleep(sleeptime) + if x.iscomplete(): + x.report() + break + x.info("All done; closing") + x.close() + sys.exit(0) + else: + parser.print_help() + sys.exit(0)