diff --git a/config/config-template-nessus.yaml b/config/config-template-nessus.yaml new file mode 100644 index 00000000..39d5997f --- /dev/null +++ b/config/config-template-nessus.yaml @@ -0,0 +1,42 @@ +config: + # WARNING: `configVersion` indicates the schema version of the config file. + # This value tells RapiDAST what schema should be used to read this configuration. + # Therefore you should only change it if you update the configuration to a newer schema + configVersion: 5 + + # all the results of all scanners will be stored under that location + # base_results_dir: "./results" + +# `application` contains data related to the application, not to the scans. +application: + shortName: "nessus-test-1.0" + # url: "" # XXX unused for nessus + +# `general` is a section that will be applied to all scanners. +# Any scanner can override a value by creating an entry of the same name in their own configuration +general: + + # XXX auth section not yet used by nessus scanner + # remove `authentication` entirely for unauthenticated connection + # authentication: + # type: "oauth2_rtoken" + # parameters: + # client_id: "cloud-services" + # token_endpoint: "" + # # rtoken_from_var: "RTOKEN" # referring to a env defined in general.environ.envFile + # #preauth: false # set to true to pregenerate a token, and stick to it (no refresh) + +# `scanners' is a section that configures scanning options +scanners: + nessus: + server: + url: https://nessus-example.com/ # URL of Nessus instance + username: foo # OR username_from_var: NESSUS_USER + password: bar # OR password_from_var: NESSUS_PASSWORD + scan: + name: test-scan # name of new scan to create + folder: test-folder # name of folder in to contain scan + policy: "py-test" # policy used for scan + # timeout: 600 # timeout in seconds to complete scan + targets: + - 127.0.0.1 diff --git a/rapidast.py b/rapidast.py index abdcf5b2..ffe803ff 100755 --- a/rapidast.py +++ b/rapidast.py @@ -6,6 +6,8 @@ import re import sys from datetime import datetime +from typing import Any +from typing import Dict from urllib import request import yaml @@ -59,6 +61,10 @@ def load_config_file(config_file_location: str): return open(config_file_location, mode="r", encoding="utf-8") +def load_config(config_file_location: str) -> Dict[str, Any]: + return yaml.safe_load(load_config_file(config_file_location)) + + def run_scanner(name, config, args, scan_exporter): """given the config `config`, runs scanner `name`. Returns: @@ -138,7 +144,7 @@ def dump_redacted_config(config_file_location: str, destination_dir: str) -> boo destination_dir: The directory where the redacted configuration file should be saved """ - logging.info("Starting the redaction and dumping process for the configuration file: {config_file_location}") + logging.info(f"Starting the redaction and dumping process for the configuration file: {config_file_location}") try: if not os.path.exists(destination_dir): diff --git a/requirements.txt b/requirements.txt index c64861c3..3ba29592 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ python-dotenv >= 1.0.0 pyyaml >= 6.0 requests >= 2.27.1 google.cloud.storage >= 2.17.0 +py_nessus_pro >= 1.2.5 +dacite >= 1.8.1 diff --git a/scanners/__init__.py b/scanners/__init__.py index c0f5df05..1de6228b 100644 --- a/scanners/__init__.py +++ b/scanners/__init__.py @@ -20,12 +20,13 @@ class State(Enum): class RapidastScanner: - def __init__(self, config, ident): + def __init__(self, config: configmodel.RapidastConfigModel, ident: str): self.ident = ident self.config = config self.state = State.UNCONFIGURED self.results_dir = os.path.join(self.config.get("config.results_dir", default="results"), self.ident) + os.makedirs(self.results_dir, exist_ok=True) # When requested to create a temporary file or directory, it will be a subdir of # this temporary directory diff --git a/scanners/generic/generic.py b/scanners/generic/generic.py index 6347e5fe..ef7c5274 100644 --- a/scanners/generic/generic.py +++ b/scanners/generic/generic.py @@ -65,7 +65,7 @@ def postprocess(self): logging.info(f"Extracting report, storing in {self.results_dir}") result = self.my_conf("results") try: - os.makedirs(self.results_dir) + os.makedirs(self.results_dir, exist_ok=True) if os.path.isdir(result): shutil.copytree(result, self.results_dir, dirs_exist_ok=True) else: diff --git a/scanners/nessus/__init__.py b/scanners/nessus/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scanners/nessus/nessus_none.py b/scanners/nessus/nessus_none.py new file mode 100644 index 00000000..6405cf0b --- /dev/null +++ b/scanners/nessus/nessus_none.py @@ -0,0 +1,146 @@ +import logging +import time +from dataclasses import dataclass +from dataclasses import field +from typing import List +from typing import Optional + +import dacite +import requests.exceptions +from py_nessus_pro import PyNessusPro + +from configmodel import RapidastConfigModel +from scanners import RapidastScanner +from scanners import State + + +@dataclass +class NessusServerConfig: + url: str + username: str + password: str + + +@dataclass +class NessusScanConfig: + name: str + policy: str + targets: List[str] + folder: str = field(default="rapidast") + timeout: int = field(default=600) # seconds + + def targets_as_str(self) -> str: + return " ".join(self.targets) + + +@dataclass +class NessusConfig: + server: NessusServerConfig + scan: NessusScanConfig + + +# XXX required by ./rapidast.py +CLASSNAME = "Nessus" + +END_STATUSES = [ + "completed", + "canceled", + "imported", + "aborted", +] + + +class Nessus(RapidastScanner): + def __init__(self, config: RapidastConfigModel, ident: str = "nessus"): + super().__init__(config, ident) + self._nessus_client: Optional[PyNessusPro] = None + self._scan_id: Optional[int] = None + nessus_config_section = config.subtree_to_dict(f"scanners.{ident}") + if nessus_config_section is None: + raise ValueError("'scanners.nessus' section not in config") + # self.auth_config = config.subtree_to_dict("general.authentication") # creds for target hosts + # XXX self.config is already a dict with raw config values + self.cfg = dacite.from_dict(data_class=NessusConfig, data=nessus_config_section) + self._sleep_interval: int = 10 + self._connect() + + def _connect(self): + logging.debug(f"Connecting to nessus instance at {self.cfg.server.url}") + try: + self._nessus_client = PyNessusPro( + self.cfg.server.url, + self.cfg.server.username, + self.cfg.server.password, + log_level="debug", + ) + except requests.exceptions.RequestException as e: + logging.error(f"Failed to connect to {self.cfg.server.url}: {e}") + raise + + @property + def nessus_client(self) -> PyNessusPro: + if self._nessus_client is None: + raise RuntimeError(f"Nessus client not connected: {self.state}") + return self._nessus_client + + @property + def scan_id(self) -> int: + if self._scan_id is None: + raise RuntimeError("scan_id is None") + return self._scan_id + + def setup(self): + logging.debug(f"Creating new scan named {self.cfg.scan.folder}/{self.cfg.scan.name}") + self._scan_id = self.nessus_client.new_scan( + name=self.cfg.scan.name, + targets=self.cfg.scan.targets_as_str(), + folder=self.cfg.scan.folder, + create_folder=True, + ) + + if self._scan_id < 0: + raise RuntimeError(f"Unexpected scan_id {self.scan_id}") + + # only user-created scan policies seem to be identified and must be + # created with the name used in the config as a prerequisite + if self.cfg.scan.policy: + logging.debug(f"Setting scan policy to {self.cfg.scan.policy}") + self.nessus_client.set_scan_policy(scan_id=self.scan_id, policy=self.cfg.scan.policy) + + self.state = State.READY + + def run(self): + if self.state != State.READY: + raise RuntimeError(f"[nessus] unexpected state: READY != {self.state}") + # State that we want the scan to launch immediately + logging.debug("Launching scan") + self.nessus_client.set_scan_launch_now(scan_id=self.scan_id, launch_now=True) + + # Tell nessus to create and launch the scan + self.nessus_client.post_scan(scan_id=self.scan_id) + + # Wait for the scan to complete + start = time.time() + while self.nessus_client.get_scan_status(self.scan_id)["status"] not in END_STATUSES: + if time.time() - start > self.cfg.scan.timeout: + logging.error(f"Timeout {self.cfg.scan.timeout}s reached waiting for scan to complete") + self.state = State.ERROR + break + + time.sleep(self._sleep_interval) + logging.debug(f"Waiting {self._sleep_interval}s for scan to finish") + logging.info(self.nessus_client.get_scan_status(self.scan_id)) + + def postprocess(self): + # After scan is complete, download report in csv, nessus, and html format + # Path and any folders must already exist in this implementation + logging.debug("Retrieving scan reports") + scan_reports = self.nessus_client.get_scan_reports(self.scan_id, self.results_dir) + logging.debug(scan_reports) + if not self.state == State.ERROR: + self.state = State.PROCESSED + + def cleanup(self): + logging.debug("cleaning up") + if not self.state == State.PROCESSED: + raise RuntimeError(f"[nessus] unexpected state: PROCESSED != {self.state}") diff --git a/scanners/nessus/nessus_podman.py b/scanners/nessus/nessus_podman.py new file mode 100644 index 00000000..d0f748d3 --- /dev/null +++ b/scanners/nessus/nessus_podman.py @@ -0,0 +1,6 @@ +CLASSNAME = "Nessus" + + +class Nessus: + def __init__(self, *args): + raise RuntimeError("nessus scanner is not supported with 'general.container.type=podman' config option") diff --git a/scanners/zap/zap.py b/scanners/zap/zap.py index 41eb0a69..2e93b6e4 100644 --- a/scanners/zap/zap.py +++ b/scanners/zap/zap.py @@ -90,7 +90,7 @@ def postprocess(self): logging.debug(f"reports_dir: {reports_dir}") logging.info(f"Extracting report, storing in {self.results_dir}") - shutil.copytree(reports_dir, self.results_dir) + shutil.copytree(reports_dir, self.results_dir, dirs_exist_ok=True) logging.info("Saving the session as evidence") with tarfile.open(f"{self.results_dir}/session.tar.gz", "w:gz") as tar: diff --git a/tests/scanners/nessus/test_nessus.py b/tests/scanners/nessus/test_nessus.py new file mode 100644 index 00000000..2c8c3d56 --- /dev/null +++ b/tests/scanners/nessus/test_nessus.py @@ -0,0 +1,28 @@ +from unittest.mock import Mock +from unittest.mock import patch + +import requests + +import configmodel +import rapidast +from scanners.nessus.nessus_none import Nessus + + +class TestNessus: + @patch("requests.Session.request") + @patch("py_nessus_pro.py_nessus_pro.BeautifulSoup") # patch where imported + @patch("py_nessus_pro.py_nessus_pro.webdriver") # patch where imported + def test_setup_nessus(self, mock_driver, mock_bs4, mock_get): + # All this mocking is for PyNessusPro.__init__() which attempts to connect to Nessus + mock_soup = Mock() + mock_soup.find_all.return_value = [{"src": "foo"}] + mock_bs4.return_value = mock_soup + mock_get.return_value = Mock(spec=requests.Response) + mock_get.return_value.status_code = 200 + mock_get.return_value.text = '{"token": "foo", "folders": []}' + + config_data = rapidast.load_config("config/config-template-nessus.yaml") + config = configmodel.RapidastConfigModel(config_data) + test_nessus = Nessus(config=config) + assert test_nessus is not None + assert test_nessus.nessus_client is not None diff --git a/tests/scanners/zap/test_setup.py b/tests/scanners/zap/test_setup.py index 7da36c62..72eba6ea 100644 --- a/tests/scanners/zap/test_setup.py +++ b/tests/scanners/zap/test_setup.py @@ -1,6 +1,4 @@ import os -import pathlib -import re from pathlib import Path import pytest