diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0bad85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,121 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# vscode +.vscode + +# venv +venv* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae8b07e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM python:alpine + +RUN pip install wallarm-fast-cli + +ENTRYPOINT [ "fast-cli" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..474e4c5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 faloker + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0620ae8 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Description +Wallarm FAST CLI - A simple command line interface for [Wallarm FAST](https://wallarm.com/products/fast/). For now, this tool can be used in CI system to create test runs and retrieve detected vulnerabilities. + +# Installation +To install the latest release from PyPI, you can run the following command: +`pip install wallarm-fast-cli` +Also, you can use docker image: +`docker pull faloker/wallarm-fast-cli` + +# Usage +``` +Usage: fast-cli [OPTIONS] COMMAND [ARGS]... + + Wallarm FAST CLI - A simple command line interface for Wallarm FAST. + +Options: + --uuid TEXT You personal UUID to authorize API calls. Defaults to the + value of the env variable WALLARM_UUID. + --secret TEXT You personal secret key to authorize API calls. Defaults to + the value of the env variable WALLARM_SECRET. + --help Show this message and exit. + +Commands: + check Check that credentials (UUID and Secret key) are valid. + create Create a new test run with provided parameters. + report Get all findings from test run by id. Findings will be in JSON + format. +``` + +Main purpose of this tool is to create test runs from CI system (i.e. Jenkins), it can be done with create command: +``` +Usage: fast-cli create [OPTIONS] + +Options: + -n, --name TEXT Test run name. [required] + -N, --node TEXT Node name for test execution. No cloud, only + node. [required] + -D, --desc TEXT Short description. Defaults to empty decription. + -P, --policy TEXT Policy name to apply. + -T, --tags TEXT Comma-separated tags to test run. + -Rt, --rps-total INTEGER The max number of concurrent requests. + -Rb, --rps-per-url INTEGER The max number of concurrent requests for one + baseline (unique url). + --track / --no-track If set, then test execution will be tracked and + all findings will be exported at the end. + -o, --out-file TEXT Save report to the file. Otherwise, the results will + be output to stdout. + --help Show this message and exit. +``` +Example of command to create test run: +`fast-cli create -n awesome_run -N super_node -P my_policy -T fast,cli,test -Rt 200 -Rb 20` \ No newline at end of file diff --git a/fastcli/__init__.py b/fastcli/__init__.py new file mode 100644 index 0000000..0387d31 --- /dev/null +++ b/fastcli/__init__.py @@ -0,0 +1 @@ +__name__ = "wallarm-fast-cli" diff --git a/fastcli/cli.py b/fastcli/cli.py new file mode 100644 index 0000000..15d205d --- /dev/null +++ b/fastcli/cli.py @@ -0,0 +1,146 @@ +import sys +import os +from time import sleep + +import click + +from fastcli.fast_helper import FASTHelper +from fastcli.log import console +from fastcli.helpers import fast_error_handler + + +@click.group( + help="Wallarm FAST CLI - A simple command line interface for Wallarm FAST." +) +@click.option( + "--uuid", + default="", + envvar="WALLARM_UUID", + type=str, + help="You personal UUID to authorize API calls. Defaults to the value of the env variable WALLARM_UUID.", +) +@click.option( + "--secret", + default="", + envvar="WALLARM_SECRET", + type=str, + help="You personal secret key to authorize API calls. Defaults to the value of the env variable WALLARM_SECRET.", +) +@click.pass_context +def cli(ctx, uuid, secret): + ctx.obj = FASTHelper(wallarm_uuid=uuid, wallarm_secret=secret) + + +@cli.command("create", short_help="Create a new test run with provided parameters.") +@click.option("--name", "-n", required=True, help="Test run name.") +@click.option( + "--node", + "-N", + required=True, + help="Node name for test execution. No cloud, only node.", +) +@click.option( + "--desc", "-D", default="", help="Short description. Defaults to empty decription." +) +@click.option("--policy", "-P", help="Policy name to apply.") +@click.option("--tags", "-T", help="Comma-separated tags to test run.") +@click.option( + "--rps-total", "-Rt", type=int, help="The max number of concurrent requests." +) +@click.option( + "--rps-per-url", + "-Rb", + type=int, + help="The max number of concurrent requests for one baseline (unique url).", +) +@click.option( + "--track/--no-track", + default=True, + is_flag=True, + help="If set, then test execution will be tracked and all findings will be exported at the end.", +) +@click.option( + "--out-file", + "-o", + help="Save report to file. Otherwise, the results will be output to stdout.", +) +@click.pass_obj +def create( + fast, name, desc, tags, node, policy, rps_total, rps_per_url, track, out_file +): + with fast_error_handler(): + test_run = fast.create_test_run( + name, desc, tags, node, policy, rps_total, rps_per_url + ) + + console.info( + "Name: {}\nState: {}\nID: {}".format( + test_run["name"], test_run["state"], test_run["id"] + ) + ) + + if track: + while True: + with fast_error_handler(): + tr = fast.fetch_test_run(test_run["id"]) + + done = tr["baseline_check_all_terminated_count"] + left = tr["baseline_count"] + state = tr["state"] + + if left: + console.info( + "Auditing {}/{} [ {} % ]".format( + done, left, round(done / left, 2) * 100 + ) + ) + else: + console.info("Waiting for baselines...") + + if state != "running": + break + else: + sleep(120) + + with fast_error_handler(): + report = fast.fetch_vulns_from_test_run(test_run["id"]) + + if out_file: + with click.open_file(out_file, "w+") as f: + f.write(report) + else: + print(report) + + +@cli.command( + "report", + short_help="Get all findings from test run by id. Findings will be in JSON format.", +) +@click.option( + "--test-run-id", "-id", required=True, help="Test run id to get issues from." +) +@click.option( + "--out-file", + "-o", + help="Save report to file. Otherwise, the results will be output to stdout.", +) +@click.pass_obj +def report(fast, test_run_id, out_file): + with fast_error_handler(): + report = fast.fetch_vulns_from_test_run(test_run_id) + + if out_file: + with click.open_file(out_file, "w+") as f: + f.write(report) + else: + print(report) + + +@cli.command( + "check", short_help="Check that credentials (UUID and Secret key) are valid." +) +@click.pass_obj +def check(fast): + with fast_error_handler(): + if fast.get_client_id(): + console.info("Credentials are valid.") diff --git a/fastcli/exceptions.py b/fastcli/exceptions.py new file mode 100644 index 0000000..979d679 --- /dev/null +++ b/fastcli/exceptions.py @@ -0,0 +1,6 @@ +class FASTError(Exception): + """Generic exception for FAST CLI.""" + + def __init__(self, message, extra=None): + super(FASTError, self).__init__(message) + self.extra = extra diff --git a/fastcli/fast_helper.py b/fastcli/fast_helper.py new file mode 100644 index 0000000..760a2ad --- /dev/null +++ b/fastcli/fast_helper.py @@ -0,0 +1,163 @@ +import sys +import requests +from requests.exceptions import RequestException +import json + +from fastcli.log import console +from fastcli.exceptions import FASTError + + +class FASTHelper: + def __init__(self, wallarm_uuid="", wallarm_secret=""): + self.wallarm_headers = { + "X-WallarmAPI-UUID": wallarm_uuid, + "X-WallarmAPI-Secret": wallarm_secret, + "Accept": "application/json", + } + + def create_test_run( + self, + name, + desc, + tags, + node, + policy, + rps_total, + rps_per_url, + stop_on_first_fail=False, + type="node", + ): + url = "https://api.wallarm.com/v1/test_run" + client_id = self.get_client_id() + payload = { + "name": name, + "desc": desc, + "node_id": self.get_node_id_by_name(client_id, node), + "stop_on_first_fail": stop_on_first_fail, + "type": type, + "clientid": client_id, + } + + if policy: + payload["policy_id"] = self.get_test_policy_by_name(client_id, policy) + + if tags: + payload["tags"] = tags.split(",") + + if rps_total: + payload["rps"] = rps_total + + if rps_per_url: + payload["rps_per_baseline"] = rps_per_url + + try: + response = requests.post(url, headers=self.wallarm_headers, json=payload) + except RequestException as e: + console.error("Request issue:\n{}".format(e)) + + if response.status_code in (200, 201): + return response.json()["body"] + else: + raise FASTError("Unable to create test run:\n{}".format(response.json())) + + def fetch_test_run(self, test_run_id): + url = "https://api.wallarm.com/v1/test_run/" + payload = str(test_run_id) + + try: + response = requests.get(url + payload, headers=self.wallarm_headers) + except RequestException as e: + console.error("Request issue:\n{}".format(e)) + + if response.status_code != 200: + raise FASTError(f"Unable to fetch test run:\n{response.json()}") + + return response.json()["body"] + + def fetch_vulns_from_test_run(self, test_run_id): + url = "https://api.wallarm.com/v1/objects/vuln" + payload = { + "filter": {"testrun_id": [int(test_run_id)]}, + "offset": 0, + "limit": 1000, + "order_desc": False, + } + + try: + response = requests.post(url, headers=self.wallarm_headers, json=payload) + except RequestException as e: + console.error("Request issue:\n{}".format(e)) + + if response.status_code != 200: + raise FASTError(f"Unable to fetch vulnerabilities:\n{response.json()}") + + return json.dumps(response.json()["body"]) + + def get_test_policy_by_name(self, client_id, name): + url = "https://api.wallarm.com/v1/test_policy" + payload = { + "filter[name]": name, + "order_by": "updated_at", + "order_desc": True, + "limit": 10, + "clientid": client_id, + } + + try: + response = requests.get(url, params=payload, headers=self.wallarm_headers) + except RequestException as e: + console.error("Request issue:\n{}".format(e)) + + if response.status_code == 200: + try: + policy_id = response.json()["body"]["objects"][0]["id"] + except: + raise FASTError(f"Unable to fetch policy:\n{response.json()}") + else: + raise FASTError(f"Unable to fetch policy:\n{response.json()}") + + return policy_id + + def get_node_id_by_name(self, client_id, node_name): + url = "https://api.wallarm.com/v2/node" + payload = { + "order_by": "hostname", + "limit": 1000, + "filter[clientid][]": client_id, + "filter[type]": "fast_node", + "filter[hostname]": node_name, + } + + try: + response = requests.get(url, params=payload, headers=self.wallarm_headers) + except RequestException as e: + console.error("Request issue:\n{}".format(e)) + + if response.status_code == 200: + try: + node_id = response.json()["body"][0]["id"] + except: + raise FASTError(f"Unable to find FAST node:\n{response.json()}") + else: + raise FASTError(f"Unable to find FAST node:\n{response.json()}") + + return node_id + + def get_client_id(self): + url = "https://api.wallarm.com/v1/user" + + try: + response = requests.post(url, headers=self.wallarm_headers) + except RequestException as e: + console.error("Request issue:\n{}".format(e)) + + if response.status_code == 200: + try: + client_id = response.json()["body"]["clientid"] + except: + raise FASTError(f"Unable to get client id:\n{response.json()}") + else: + raise FASTError(f"Unable to get client id:\n{response.json()}") + + return client_id + diff --git a/fastcli/helpers.py b/fastcli/helpers.py new file mode 100644 index 0000000..74c3c4c --- /dev/null +++ b/fastcli/helpers.py @@ -0,0 +1,15 @@ +from contextlib import contextmanager +import sys + +from fastcli.exceptions import FASTError +from fastcli.log import console + + +@contextmanager +def fast_error_handler(): + """Context manager to handle FASTError exceptions in a standard way.""" + try: + yield + except FASTError as e: + console.error(str(e)) + sys.exit(1) diff --git a/fastcli/log.py b/fastcli/log.py new file mode 100644 index 0000000..7c2eab8 --- /dev/null +++ b/fastcli/log.py @@ -0,0 +1,17 @@ +import logging + +console = logging.getLogger("fast") +console.setLevel(logging.DEBUG) + +# create console handler and set level to debug +ch = logging.StreamHandler() +ch.setLevel(logging.DEBUG) + +# create formatter +formatter = logging.Formatter("%(message)s") + +# add formatter to ch +ch.setFormatter(formatter) + +# add ch to logger +console.addHandler(ch) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..23e9d1a --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +import os +from setuptools import setup + +with open('README.md', 'r') as f: + long_description = f.read() + +if os.environ.get('CI_COMMIT_TAG'): + version = os.environ['CI_COMMIT_TAG'] +else: + version = os.environ['CI_JOB_ID'] + +setup( + name='wallarm-fast-cli', + version=version, + description='A Wallarm FAST CLI tool for executing tests and getting results from the command line.', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/faloker/wallarm-fast-cli', + license='MIT', + packages=[ + 'fastcli', + ], + install_requires=[ + 'click==7.0', + 'requests==2.23.0', + ], + entry_points={ + 'console_scripts': [ + 'fast-cli=fastcli.cli:cli', + ], + }, + classifiers=[ + 'Topic :: Security', + 'Topic :: Software Development :: Quality Assurance', + 'Topic :: Software Development :: Testing', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Programming Language :: Python :: 3.7', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + ], +)