From 61b8069fb69fad22997100d5893f97f683f418f2 Mon Sep 17 00:00:00 2001 From: Damian Krawczyk Date: Tue, 24 Aug 2021 22:59:43 +0200 Subject: [PATCH] initial version --- .github/workflows/python-package.yml | 41 ++++ .github/workflows/python-publish.yml | 36 ++++ .gitignore | 7 + CHANGELOG.md | 16 ++ LICENSE | 21 ++ README.md | 55 ++++- requirements.txt | 7 + setup.py | 38 ++++ tnscm/__init__.py | 1 + tnscm/__main__.py | 308 +++++++++++++++++++++++++++ tnscm/_version.py | 1 + tnscm/modules/__init__.py | 0 tnscm/modules/tnsapi/__init__.py | 150 +++++++++++++ 13 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/python-package.yml create mode 100644 .github/workflows/python-publish.yml create mode 100644 .gitignore create mode 100755 CHANGELOG.md create mode 100755 LICENSE mode change 100644 => 100755 README.md create mode 100755 requirements.txt create mode 100755 setup.py create mode 100755 tnscm/__init__.py create mode 100644 tnscm/__main__.py create mode 100755 tnscm/_version.py create mode 100755 tnscm/modules/__init__.py create mode 100644 tnscm/modules/tnsapi/__init__.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..2a4b668 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,41 @@ +name: Python package + +on: [push] + +env: + python_package_name: tnscm + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: [3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install tools + run: | + python -m pip install --upgrade pip build setuptools wheel twine + - name: Install dependencies + run: | + pip install -r requirements.txt + - name: Build package + run: | + python setup.py sdist bdist_wheel + - name: Install locally + run: | + ls + ls dist + TOOL_CURRENT_VERSION=`sed -e 's/.*__version__ = "\(.*\)".*/\1/' ${{ env.python_package_name }}/_version.py` + pip install dist/${{ env.python_package_name }}-${TOOL_CURRENT_VERSION}-py3-none-any.whl + shell: bash + - name: pip show package + run: | + pip show ${{ env.python_package_name }} \ No newline at end of file diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..78ba265 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,36 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build setuptools wheel twine + - name: Build package + run: python setup.py sdist bdist_wheel + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..affa9b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build +dist +.idea +.DS_Store +*.egg-info +__pycache__ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 0000000..72f1ea1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to [**TNSCM** *(Tenable Nessus CLI Manager)* by LimberDuck][1] project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [0.0.1] - 2021-08-24 + +- initial release + + +[0.0.1]: https://github.com/LimberDuck/tnscm/releases/tag/v0.0.1 + +[1]: https://github.com/LimberDuck/tnscm diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..f0091ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Tenable, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 6c8a20d..629bd51 --- a/README.md +++ b/README.md @@ -1 +1,54 @@ -# tnscm \ No newline at end of file +# TNSCM + +**TNSCM** *(Tenable Nessus CLI Manager)* by LimberDuck is a CLI tool which enables you to perform certain actions on Nessus by (C) Tenable, Inc. via Nessus API. + +[![PyPI - Downloads](https://img.shields.io/pypi/dm/tnscm?logo=PyPI)](https://pypi.org/project/tnscm/) [![License](https://img.shields.io/github/license/LimberDuck/tnscm.svg)](https://github.com/LimberDuck/tnscm/blob/main/LICENSE) [![Repo size](https://img.shields.io/github/repo-size/LimberDuck/tnscm.svg)](https://github.com/LimberDuck/tnscm) [![Code size](https://img.shields.io/github/languages/code-size/LimberDuck/tnscm.svg)](https://github.com/LimberDuck/tnscm) [![Supported platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux-lightgrey.svg)](https://github.com/LimberDuck/tnscm) + + +Main features +============= + +Initial version of **TNSCM** lets you get Nessus: +* scans list +* policies list +* users list +* status of available IPs + + +How to +====== + +1. Install + + `pip install tnscm` + +2. Run + + `tnscm` + +Meta +==== + +Change log +---------- + +See [CHANGELOG]. + + +Licence +------- + +MIT: [LICENSE]. + + +Authors +------- + +[Damian Krawczyk] created **TNSCM** *(Tenable Nessus CLI Manager)* by LimberDuck. + +[Damian Krawczyk]: https://damiankrawczyk.com + +[CHANGELOG]: https://github.com/LimberDuck/tnscm/blob/main/CHANGELOG.md +[LICENSE]: https://github.com/LimberDuck/tnscm/blob/main/LICENSE + + diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..b4370e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +certstore>=0.0.2 +click>=8.0.1 +keyring>=23.0.1 +oauthlib>=3.1.1 +requests>=2.25.1 +pandas>=1.3.2 +tabulate>=0.8.9 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..718393b --- /dev/null +++ b/setup.py @@ -0,0 +1,38 @@ +import setuptools + +with open("README.md", "r") as fh: + long_description = fh.read() + +with open('requirements.txt') as f: + required = f.read().splitlines() + +about = {} +with open("tnscm/_version.py") as f: + exec(f.read(), about) + +setuptools.setup( + name="tnscm", + version=about["__version__"], + author="Damian Krawczyk", + author_email="damian.krawczyk@limberduck.org", + description="TNSCM (Tenable Nessus CLI Manager) by LimberDuck", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/LimberDuck/tnscm", + packages=setuptools.find_packages(), + install_requires=required, + entry_points={ + "console_scripts": [ + "tnscm = tnscm.__main__:main" + ] + }, + classifiers=[ + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.7", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 4 - Beta", + "Environment :: Console", + ], +) \ No newline at end of file diff --git a/tnscm/__init__.py b/tnscm/__init__.py new file mode 100755 index 0000000..3ec2acf --- /dev/null +++ b/tnscm/__init__.py @@ -0,0 +1 @@ +from tnscm._version import __version__ diff --git a/tnscm/__main__.py b/tnscm/__main__.py new file mode 100644 index 0000000..a617250 --- /dev/null +++ b/tnscm/__main__.py @@ -0,0 +1,308 @@ +from tnscm._version import __version__ +from tnscm.modules.tnsapi import TnsApi +import click +import pandas as pd +from tabulate import tabulate +import getpass +import keyring +from keyring.backends import Windows, macOS +import platform +import sys +from oauthlib.oauth2.rfc6749.errors import CustomOAuth2Error + +os_user = getpass.getuser().lower() + +if platform.system() == 'Windows': + keyring.set_keyring(Windows.WinVaultKeyring()) +elif platform.system() == 'Darwin': + keyring.set_keyring(macOS.Keyring()) + + +_login_options = [ + click.option('--address', '-a', default=["127.0.0.1"], multiple=True, prompt='address', + help='address to which you want to login', + show_default="127.0.0.1"), + click.option('--port', default="443", + help='port to which you want to login', + show_default="443"), + click.option('--username', '-u', default=os_user, prompt='username', + help='username which you want to use to login', + show_default="current user"), + click.option('--password', '-p', + help='password which you want to use to login'), + click.option('--insecure', '-k', is_flag=True, + help="perform insecure SSL connections and transfers"), + click.option('--format', '-f', default='table', + help='data format to display [table,json]', + show_default="table") +] + +_general_options = [ + click.option('-v', '--verbose', count=True) +] + + +def add_options(options): + def _add_options(func): + for option in reversed(options): + func = option(func) + return func + return _add_options + + +def set_vault_password(address, username, password): + password_from_vault = keyring.get_password(address, username) + if password_from_vault is None: + keyring.set_password(address, username, password) + if platform.system() == 'Windows': + print("Credentials successfully saved to Windows Credential Manager.") + print("Windows OS: Your credentials will be stored here " + "Control Panel > Credential Manager > Windows Credential > Generic Credentials. " + "You can remove or update it anytime.") + + if platform.system() == 'Darwin': + print("Credentials successfully saved to macOS Credential Manager.") + print("macOS: Your credentials will be stored here " + "Keychain Access > search for \"" + address + "\". " + "You can remove or update it anytime.") + + elif password_from_vault is not None and password_from_vault != password: + print('Password for {} @ {} already exist in OS Credential Manager and ' + 'is different than provided by you!'.format(username, address)) + + vault_update_answer = input('Do you want to update password in ' + 'OS Credential Manager? (yes): '.format(username, + address)) or "yes" + + if vault_update_answer == 'yes': + keyring.set_password(address, username, password) + if platform.system() == 'Windows': + print("Credentials successfully saved to Windows Credential Manager.") + print("Windows OS: Your credentials will be stored here " + "Control Panel > Credential Manager > Windows Credential > Generic Credentials. " + "You can remove or update it anytime.") + + if platform.system() == 'Darwin': + print("Credentials successfully saved to macOS Credential Manager.") + print("macOS: Your credentials will be stored here " + "Keychain Access > search for \"" + address + "\". " + "You can remove or update it anytime.") + + +def get_vault_password(address, username, verbose): + password = None + if platform.system() == 'Windows' or platform.system() == 'Darwin': + if verbose: + print("Looking for password in OS Credential Manager") + password_from_vault = keyring.get_password(address, username) + if password_from_vault: + password = password_from_vault + if verbose: + print("Password found.") + else: + if verbose: + print("Password not found.") + + return password + + +def password_check(address, username, password, verbose): + if not password: + password = get_vault_password(address, username, verbose) + + if not password: + password = click.prompt("password", hide_input=True, confirmation_prompt=True) + set_vault_password(address, username, password) + + if password: + set_vault_password(address, username, password) + + return password + + +def dataframe_table(data, sortby=None, groupby=None, tablefmt=None): + pd.set_option('display.max_rows', None) + pd.set_option('display.max_columns', None) + pd.set_option('display.width', None) + pd.set_option('display.max_colwidth', None) + df = pd.DataFrame(data) + df.head() + if sortby: + df = df.sort_values(by=sortby) + if groupby: + df = df.groupby(groupby)[groupby[0]].count().reset_index(name="count") + s = pd.Series(list(range(1, len(df) + 1))) + df = df.set_index(s) + if tablefmt: + df = str(tabulate(df, headers='keys', tablefmt=tablefmt)) + return df + + +@click.group() +def cli(): + pass + + +@cli.command() +@add_options(_login_options) +@add_options(_general_options) +@click.option('--status', is_flag=True, + help="Get server status") +@click.option('--ips', is_flag=True, + help="Use to see number of licensed IPs, active IPs and left IPs") +@click.option('--version', is_flag=True, + help="Get server version") +def server(address, port, username, password, insecure, format, status, ips, version, verbose): + """get Nessus server info""" + + for one_address in address: + one_password = password_check(one_address, username, password, verbose) + + try: + tnscon = TnsApi(one_address, port, insecure) + tnscon.login(username, one_password) + except ConnectionError as e: + print("Can't reach Nessus API via {} Please check your connection.".format(one_address)) + sys.exit(1) + + except CustomOAuth2Error as e: + print("Can't login to Nessus API with supplied credentials. Please make sure they are correct.") + sys.exit(1) + + if status: + server_status = tnscon.server_status_get() + print(server_status) + if ips: + server_properties = tnscon.server_properties_get() + licensed_ips = server_properties['license']['ips'] + active_ips = server_properties['used_ip_count'] + left_ips = int(licensed_ips) - int(active_ips) + left_ips_percentage = str(int(100 - 100 * int(active_ips) / int(licensed_ips))) + print(one_address + ' ' + '{0:}'.format(int(licensed_ips)) + ' - ' + '{0:}'.format(int(active_ips)) + + ' = ' + '{0:}'.format(left_ips) + ' (' + left_ips_percentage + '%) remaining IPs') + + if version: + server_version =tnscon.server_properties_get()['server_version'] + print(server_version) + + tnscon.logout() + + +@cli.command() +@add_options(_login_options) +@add_options(_general_options) +@click.option('--list', is_flag=True, + help="Get users list") +def users(address, port, username, password, insecure, format, list, verbose): + """get Nessus user info""" + + for one_address in address: + one_password = password_check(one_address, username, password, verbose) + + try: + tnscon = TnsApi(one_address, port, insecure) + tnscon.login(username, one_password) + except ConnectionError as e: + print("Can't reach Nessus API via {} Please check your connection.".format(one_address)) + sys.exit(1) + + except CustomOAuth2Error as e: + print("Can't login to Nessus API with supplied credentials. Please make sure they are correct.") + sys.exit(1) + + if list: + print(one_address) + users_on_nessus = tnscon.users_get() + if format == 'table': + print(dataframe_table(users_on_nessus)) + else: + print(users_on_nessus) + + else: + print("No option given!") + + tnscon.logout() + + +@cli.command() +@add_options(_login_options) +@add_options(_general_options) +@click.option('--list', is_flag=True, + help="Get scan policies list") +def policies(address, port, username, password, insecure, format, list, verbose): + """get Nessus policy info""" + + for one_address in address: + one_password = password_check(one_address, username, password, verbose) + + try: + tnscon = TnsApi(one_address, port, insecure) + tnscon.login(username, one_password) + except ConnectionError as e: + print("Can't reach Nessus API via {}. Please check your connection.".format(one_address)) + sys.exit(1) + + except CustomOAuth2Error as e: + print("Can't login to Nessus API with supplied credentials. Please make sure they are correct.") + sys.exit(1) + + if list: + print(one_address) + scan_policies_on_nessus = tnscon.policies_get() + if scan_policies_on_nessus is not None: + if format == 'table': + print(dataframe_table(scan_policies_on_nessus)) + else: + print(scan_policies_on_nessus) + else: + print('{} doesn\'t have any policies!'.format(username)) + else: + print("No option given!") + + tnscon.logout() + + +@cli.command() +@add_options(_login_options) +@add_options(_general_options) +@click.option('--list', is_flag=True, + help="Get scans list") +def scans(address, port, username, password, insecure, format, list, verbose): + """get Nessus scan info""" + + for one_address in address: + one_password = password_check(one_address, username, password, verbose) + + try: + tnscon = TnsApi(one_address, port, insecure) + tnscon.login(username, one_password) + except ConnectionError as e: + print("Can't reach Nessus API via {}. Please check your connection.".format(one_address)) + sys.exit(1) + + except CustomOAuth2Error as e: + print("Can't login to Nessus API with supplied credentials. Please make sure they are correct.") + sys.exit(1) + + if list: + print(one_address) + scans_on_nessus = tnscon.scans_get() + if format == 'table': + print(dataframe_table(scans_on_nessus)) + else: + print(scans_on_nessus) + + else: + print("No option given!") + + tnscon.logout() + + +def main(): + + print('tnscm v.{}'.format( __version__)) + cli() + + +if __name__ == '__main__': + main() diff --git a/tnscm/_version.py b/tnscm/_version.py new file mode 100755 index 0000000..b3c06d4 --- /dev/null +++ b/tnscm/_version.py @@ -0,0 +1 @@ +__version__ = "0.0.1" \ No newline at end of file diff --git a/tnscm/modules/__init__.py b/tnscm/modules/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/tnscm/modules/tnsapi/__init__.py b/tnscm/modules/tnsapi/__init__.py new file mode 100644 index 0000000..55aa9a1 --- /dev/null +++ b/tnscm/modules/tnsapi/__init__.py @@ -0,0 +1,150 @@ +import requests +import json +import certstore +import urllib3 +import sys +import datetime + + +class TnsApi: + + def __init__(self, host='127.0.0.1', port=443, insecure=None): + """ + :param host: address to Nessus API `127.0.0.1` + :param port: port to Nessus API `443` + :param insecure: if True perform insecure SSL connections and transfers + """ + self.host = host + self.port = port + self.verify = certstore.ca_bundle + + if not insecure: + self.verify = certstore.ca_bundle + else: + self.verify = False + urllib3.disable_warnings() + + self._token = '' + + def build_url(self, resource): + url = '{}://{}:{}'.format('https', self.host, self.port) + return '{}{}'.format(url, resource) + + def connect(self, method, resource, data=None): + + headers = {'X-Cookie': 'token={0}'.format(self._token), + 'content-type': 'application/json'} + + data = json.dumps(data) + + if method == 'POST': + r = requests.post(self.build_url(resource), data=data, headers=headers, verify=self.verify) + elif method == 'PUT': + r = requests.put(self.build_url(resource), data=data, headers=headers, verify=self.verify) + elif method == 'DELETE': + r = requests.delete(self.build_url(resource), data=data, headers=headers, verify=self.verify) + else: + r = requests.get(self.build_url(resource), data=data, headers=headers, verify=self.verify) + + if r.status_code == 401: + print('Response code: {}'.format(r.status_code)) + print('Unauthorized.') + sys.exit() + if r.status_code == 500: + print('Response code: {}'.format(r.status_code)) + print('Internal Server Error.') + sys.exit() + if r.status_code == 503: + print('Response code: {}'.format(r.status_code)) + print('Service Unavailable.') + sys.exit() + + if method == 'POST': + return r.json() + elif method == 'PUT': + if r is None: + return None + elif method == 'DELETE': + return r + else: + if 'download' in resource: + return r.content + else: + return r.json() + + def login(self, usr, pwd): + """ + Login to Nessus. + """ + + login = {'username': usr, 'password': pwd} + data = self.connect('POST', '/session', data=login) + self._token = data['token'] + return self._token + + def logout(self): + """ + Logout of Nessus. + """ + self.connect('DELETE', '/session') + + def data_limiter(self, data, limiter): + """ + Function limits data returned by application to given columns. + :param data: data to limit + :param limiter: list of columns names expected in data + :return: data limited to given column names + """ + data_clean = [] + + if limiter: + # print('limiter set') + for data_entry in data: + data_entry_clean = {} + for limiter_entry in limiter: + if limiter_entry in data_entry: + if limiter_entry == 'creation_date' or \ + limiter_entry == 'last_modification_date': + data_entry_clean.update({limiter_entry: datetime.datetime.fromtimestamp(data_entry[limiter_entry])}) + elif limiter_entry == 'lastlogin': + data_entry_clean.update({limiter_entry: data_entry[limiter_entry]}) + else: + data_entry_clean.update({limiter_entry: data_entry[limiter_entry]}) + data_clean.append(data_entry_clean) + else: + # print('limiter not set') + data_clean = data + # print(len(data_clean)) + + return data_clean + + def session_get(self): + data = self.connect('GET', '/session') + return data + + def server_status_get(self): + data = self.connect('GET', '/server/status')['status'] + return data + + def server_properties_get(self): + data = self.connect('GET', '/server/properties') + return data + + def policies_get(self): + data = self.connect('GET', '/policies')['policies'] + data = self.data_limiter(data, ['id', 'name', 'creation_date', 'last_modification_date', 'owner']) + return data + + def users_get(self): + data = self.connect('GET', '/users')['users'] + data = self.data_limiter(data, ['id', 'username', 'name', 'lastlogin']) + return data + + def folders_get(self): + data = self.connect('GET', '/folders') + return data + + def scans_get(self): + data = self.connect('GET', '/scans')['scans'] + data = self.data_limiter(data, ['folder_id', 'id', 'name', 'creation_date', 'last_modification_date', 'owner']) + return data \ No newline at end of file