Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

initial nessus integration #230

Merged
merged 9 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions config/config-template-nessus.yaml
Original file line number Diff line number Diff line change
@@ -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: "<Mandatory. root URL of the application>" # 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: "<token retrieval URL>"
# # 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
8 changes: 7 additions & 1 deletion rapidast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion scanners/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scanners/generic/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Empty file added scanners/nessus/__init__.py
Empty file.
146 changes: 146 additions & 0 deletions scanners/nessus/nessus_none.py
Original file line number Diff line number Diff line change
@@ -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(
sfowl marked this conversation as resolved.
Show resolved Hide resolved
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:
sfowl marked this conversation as resolved.
Show resolved Hide resolved
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}")
6 changes: 6 additions & 0 deletions scanners/nessus/nessus_podman.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion scanners/zap/zap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions tests/scanners/nessus/test_nessus.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions tests/scanners/zap/test_setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import os
import pathlib
import re
from pathlib import Path

import pytest
Expand Down
Loading