From d2daadad38af441b6a813aec7e2e3861ea3afa6a Mon Sep 17 00:00:00 2001 From: Diego Tejada Date: Fri, 1 Nov 2024 11:14:17 -0500 Subject: [PATCH] feat: Add API Key authentication support BIPS-21958 (#39) * feat: add API Key to action * docs: add API key to action docs * refactor: split dev and runtime requirements * fix: remove unused dependency * fix: removed redundant entrypoint * feat: defined .gitignore config * feat: add api_key to readme * fix: tests is a module * refactor: code and docstrings * chore: test code format * chore: Dockerfile cleaning * test: temporary use Dockerfile in action.yml for testing purposes * fix: absolute path for app entry point --- .gitignore | 119 ++++++++++++++++++++ Dockerfile | 23 +--- README.md | 6 +- action.yml | 11 +- main.py | 4 - requirements-dev.txt | 2 + requirements.txt | 4 +- src/main.py | 241 +++++++++++++++++++++++++--------------- tests/__init__.py | 0 tests/unit/__init__.py | 0 tests/unit/test_main.py | 55 +++++---- 11 files changed, 325 insertions(+), 140 deletions(-) delete mode 100644 main.py create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py diff --git a/.gitignore b/.gitignore index e69de29..4aa02c4 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,119 @@ +# 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/ +pip-wheel-metadata/ +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 +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# 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/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# VS Code settings +.vscode/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index dfc74a6..82b9b93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,8 @@ FROM python:3.11-alpine # setup environment variable -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 RUN python -m pip install --upgrade pip @@ -12,20 +12,9 @@ RUN apk update && apk upgrade -i -a --update-cache WORKDIR /usr/src/app # Installing requirements from requirements.txt file -# COPY requirements.txt /usr/src/app -# RUN pip install -r requirements.txt +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -# Installing secrets_safe library -# COPY secrets_safe /usr/src/app/secrets_safe +COPY src/ . -# Installing requirements from requirements.txt file -COPY requirements.txt /usr/src/app -RUN pip install -r requirements.txt - - - -COPY src /src - -COPY main.py /main.py - -ENTRYPOINT ["python", "/main.py"] +ENTRYPOINT ["python", "/usr/src/app/main.py"] \ No newline at end of file diff --git a/README.md b/README.md index 224be7f..2b23b7b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ Runners must use a Linux operating system. Additionally, self-hosted runners wil ## Inputs +### `api_key` + +**Optional:** The API Key configured in BeyondInsight for your application. If not set, then client credentials must be provided. + ### `client_id` **Required:** API OAuth Client ID. @@ -31,7 +35,7 @@ Runners must use a Linux operating system. Additionally, self-hosted runners wil **Required:** BeyondTrust Password Safe API URL. ``` -https://example.com:443/beyondtrust/api/public/V3 +https://example.com:443/BeyondTrust/api/public/v3 ``` ### `secret_path` diff --git a/action.yml b/action.yml index 7bcccea..25dfeed 100644 --- a/action.yml +++ b/action.yml @@ -2,13 +2,17 @@ name: 'Secrets Safe Action' author: 'BeyondTrust Corporation' description: 'This custom action allows for the retrieval of ASCII secrets from an instance of Secrets Safe.' inputs: + api_key: + description: 'The API Key configured in BeyondInsight for your application. If not set, then client credentials must be provided.' + required: false + default: '' client_id: description: 'The API OAuth Client ID is configured in BeyondInsight for your application. For use when authenticating to Secrets Safe' - required: true + required: false default: '' client_secret: description: 'The API OAuth Client Secret is configured in BeyondInsight for your application. For use when authenticating to Secrets Safe.' - required: true + required: false default: '' api_url: description: 'The API URL for the Secrets Safe instance from which to request a secret.' @@ -47,8 +51,9 @@ outputs: description: 'The action stores the retrieved secrets in output variables defined by the end user. The must be a unique identifier within the outputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _.' runs: using: 'docker' - image: 'docker://beyondtrust/secrets-github-action:1.0.1' + image: 'Dockerfile' args: + - ${{ inputs.api_key }} - ${{ inputs.client_id }} - ${{ inputs.client_secret }} - ${{ inputs.api_url }} diff --git a/main.py b/main.py deleted file mode 100644 index b4f3623..0000000 --- a/main.py +++ /dev/null @@ -1,4 +0,0 @@ -from src.main import main - -# calling main method -main() \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c5100e1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +coverage==7.3.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2fa1d0f..d234a3e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,4 @@ requests==2.32.3 retry-requests==2.0.0 github_action_utils==1.1.0 -coverage==7.3.1 -cryptography==43.0.1 -my-secrets-safe-library==2.0 \ No newline at end of file +beyondtrust-bips-library~=1.0 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 0b2768f..2a755a4 100644 --- a/src/main.py +++ b/src/main.py @@ -1,26 +1,30 @@ +import json +import logging import os import sys import uuid -import logging -import json -import secrets_safe_library -import requests -from retry_requests import retry -from secrets_safe_library import secrets_safe, authentication, utils, managed_account +import requests +import secrets_safe_library from github_action_utils import error +from retry_requests import retry +from secrets_safe_library import authentication, managed_account, secrets_safe, utils env = os.environ -CLIENT_ID = env["CLIENT_ID"] if 'CLIENT_ID' in env else None -CLIENT_SECRET = env["CLIENT_SECRET"] if 'CLIENT_SECRET' in env else None -API_URL = env["API_URL"] if 'API_URL' in env else None -VERIFY_CA = False if 'VERIFY_CA' in env and env['VERIFY_CA'].lower() == 'false' else True -SECRET_PATH = env['INPUT_SECRET_PATH'].strip() if 'INPUT_SECRET_PATH' in env else None -MANAGED_ACCOUNT_PATH = env['INPUT_MANAGED_ACCOUNT_PATH'].strip() if 'INPUT_MANAGED_ACCOUNT_PATH' in env else None -PATH_SEPARATOR = env['PATH_SEPARATOR'].strip() if 'PATH_SEPARATOR' in env and len(env['PATH_SEPARATOR'].strip()) == 1 else "/" +API_KEY = env.get("API_KEY") +CLIENT_ID = env.get("CLIENT_ID") +CLIENT_SECRET = env.get("CLIENT_SECRET") +API_URL = env.get("API_URL") +VERIFY_CA = env.get("VERIFY_CA", "true").lower() != "false" -LOG_LEVEL = env['LOG_LEVEL'].strip().upper() if 'LOG_LEVEL' in env else "INFO" +SECRET_PATH = env.get("INPUT_SECRET_PATH", "").strip() or None +MANAGED_ACCOUNT_PATH = env.get("INPUT_MANAGED_ACCOUNT_PATH", "").strip() or None +path_sep = env.get("PATH_SEPARATOR", "/").strip() +PATH_SEPARATOR = path_sep if len(path_sep) == 1 else "/" +MAX_SECRETS_TO_RETRIEVE = 20 + +LOG_LEVEL = env.get("LOG_LEVEL", "INFO").strip().upper() LOG_LEVELS = { "CRITICAL": 50, @@ -30,67 +34,76 @@ "WARN": 30, "INFO": 20, "DEBUG": 10, - "NOTSET": 0 + "NOTSET": 0, } LOGGER_NAME = "custom_logger" logging.basicConfig( - format = '%(asctime)-5s %(name)-15s %(levelname)-8s %(message)s', - level = LOG_LEVELS[LOG_LEVEL] + format="%(asctime)-5s %(name)-15s %(levelname)-8s %(message)s", + level=LOG_LEVELS[LOG_LEVEL], ) logger = logging.getLogger(LOGGER_NAME) TIMEOUT_CONNECTION_SECONDS = 30 TIMEOUT_REQUEST_SECONDS = 30 -CERTIFICATE = env['CERTIFICATE'].replace(r'\n', '\n') if 'CERTIFICATE' in env else None -CERTIFICATE_KEY = env['CERTIFICATE_KEY'].replace(r'\n', '\n') if 'CERTIFICATE_KEY' in env else None +CERTIFICATE = env.get("CERTIFICATE", "").replace(r"\n", "\n") +CERTIFICATE_KEY = env.get("CERTIFICATE_KEY", "").replace(r"\n", "\n") COMMAND_MARKER: str = "::" -def append_output(name, value): + +def append_output(name: str, value: str) -> None: """ - Create a variable in step output. + Appends a named value to the GitHub Actions step output file. + Arguments: - output variable name - output content + name (str): The name of the output variable. + value (str): The content to be written as the output. + Returns: + None """ - with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: delimiter = uuid.uuid1() - print(f'{name}<<{delimiter}', file=fh) + print(f"{name}<<{delimiter}", file=fh) print(value, file=fh) print(delimiter, file=fh) -def mask_secret(command, secret_to_mask): +def mask_secret(command: str, secret_to_mask: str) -> None: """ - Mask secret to avoid print it out in console. + Masks a secret by modifying the command to prevent it from being printed + in the console. + Arguments: - Masking command - Secret to mask + command (str): The command associated with the secret. + secret_to_mask (str): The secret text to be masked. + Returns: + None """ - lines = secret_to_mask.split('\n') + lines = secret_to_mask.split("\n") for line in lines: if line.strip() != "": - full_command = ( - f"{COMMAND_MARKER}{command} " - f"{COMMAND_MARKER}{line}" - ) + full_command = f"{COMMAND_MARKER}{command} {COMMAND_MARKER}{line}" print(full_command) -def show_error(error_message): +def show_error(error_message: str) -> None: """ - Show error + Displays an error message in the logs and prints an error message in the + GitHub Actions shell. + Arguments: - Error message + error_message (str): The message to display as an error. + Returns: + None """ - + error( error_message, title="Action Failed", @@ -98,85 +111,137 @@ def show_error(error_message): end_column=2, line=4, end_line=5, - ) + ) utils.print_log(logger, error_message, logging.ERROR) sys.exit(1) - - -def get_secrets(secret_obj, secrets): + + +def get_secrets( + secret_obj: authentication.Authentication | secrets_safe.SecretsSafe, + secrets: str +) -> None: """ - Call secret safe library + Retrieves secrets using the provided secret object and a JSON string of + secrets. Output is appended to GITHUB_OUTPUT. + Arguments: - Secret obj instance - List of secrets or managed acocunts + secret_obj (Authentication | SecretsSafe): An instance of either + Authentication or SecretsSafe class, handling secret operations. + secrets (str): A JSON string containing a list of secrets or managed + accounts. + Returns: + None """ try: secrets_to_retrive = json.loads(secrets) + except json.JSONDecodeError as e: + show_error(f"JSON object is not correctly formatted: {e}") + except TypeError as e: + show_error(f"Input is not a string, bytes or bytearray: {e}") except Exception as e: - show_error(f"String could not be converted to JSON: {e}") - + show_error(f"An unexpected error occurred: {e}") + if not isinstance(secrets_to_retrive, list): secrets_to_retrive = [secrets_to_retrive] - - if len(secrets_to_retrive) > 20: - show_error("The Secrets Safe action can request a maximum of 20 secrets and 20 managed accounts each run") + + if len(secrets_to_retrive) > MAX_SECRETS_TO_RETRIEVE: + show_error( + "The Secrets Safe action can request a maximum of " + f"{MAX_SECRETS_TO_RETRIEVE} secrets and " + f"{MAX_SECRETS_TO_RETRIEVE} managed accounts each run" + ) for secret_to_retrieve in secrets_to_retrive: - - if 'path' not in secret_to_retrieve: - show_error(f"Invalid JSON, validate path attribute name") - - if 'output_id' not in secret_to_retrieve: - show_error(f"Invalid JSON, validate output_id attribute name") - - get_secret_response = secret_obj.get_secret(secret_to_retrieve['path']) - + if "path" not in secret_to_retrieve: + show_error("Invalid JSON, validate path attribute name") + + if "output_id" not in secret_to_retrieve: + show_error("Invalid JSON, validate output_id attribute name") + + get_secret_response = secret_obj.get_secret(secret_to_retrieve["path"]) + mask_secret("add-mask", get_secret_response) - append_output(secret_to_retrieve['output_id'], get_secret_response) - + append_output(secret_to_retrieve["output_id"], get_secret_response) + -def main(): - try: +def main() -> None: + try: with requests.Session() as session: - req = retry(session, retries=3, backoff_factor=0.2, status_to_retry=(400,408,500,502,503,504)) - - certificate, certificate_key = utils.prepare_certificate_info(CERTIFICATE, CERTIFICATE_KEY) - - authentication_obj = authentication.Authentication( - req, - TIMEOUT_CONNECTION_SECONDS, - TIMEOUT_REQUEST_SECONDS, - API_URL, - CLIENT_ID, - CLIENT_SECRET, - certificate, - certificate_key, - VERIFY_CA, - logger) - + req = retry( + session, + retries=3, + backoff_factor=0.2, + status_to_retry=(400, 408, 500, 502, 503, 504), + ) + + certificate, certificate_key = utils.prepare_certificate_info( + CERTIFICATE, CERTIFICATE_KEY + ) + + auth_config = { + "req": req, + "timeout_connection": TIMEOUT_CONNECTION_SECONDS, + "timeout_request": TIMEOUT_REQUEST_SECONDS, + "api_url": API_URL, + "certificate": certificate, + "certificate_key": certificate_key, + "verify_ca": VERIFY_CA, + "logger": logger, + } + + # If API_KEY is set, we're using API Key authentication + # otherwise we're using OAuth/Client Credentials. + if API_KEY: + auth_config.update({"api_key": API_KEY}) + else: + auth_config.update( + {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET} + ) + + authentication_obj = authentication.Authentication(**auth_config) get_api_access_response = authentication_obj.get_api_access() - - utils.print_log(logger, f"{secrets_safe_library.__library_name__} version: {secrets_safe_library.__version__}", logging.DEBUG) - + + utils.print_log( + logger, + f"{secrets_safe_library.__library_name__} version: {secrets_safe_library.__version__}", + logging.DEBUG, + ) + if get_api_access_response.status_code != 200: - error_message = f"Please check credentials, error {get_api_access_response.text}" + error_message = ( + f"Please check credentials, error {get_api_access_response.text}" + ) show_error(error_message) - + if not SECRET_PATH and not MANAGED_ACCOUNT_PATH: - error_message = f"Nothing to do, SECRET and MANAGED_ACCOUNT parameters are empty" + error_message = ( + "Nothing to do, SECRET and MANAGED_ACCOUNT parameters are empty" + ) show_error(error_message) if SECRET_PATH: - secrets_safe_obj = secrets_safe.SecretsSafe(authentication=authentication_obj, logger=logger, separator=PATH_SEPARATOR) + secrets_safe_obj = secrets_safe.SecretsSafe( + authentication=authentication_obj, + logger=logger, + separator=PATH_SEPARATOR, + ) get_secrets(secrets_safe_obj, SECRET_PATH) - + if MANAGED_ACCOUNT_PATH: - managed_account_obj = managed_account.ManagedAccount(authentication=authentication_obj, logger=logger, separator=PATH_SEPARATOR) + managed_account_obj = managed_account.ManagedAccount( + authentication=authentication_obj, + logger=logger, + separator=PATH_SEPARATOR, + ) get_secrets(managed_account_obj, MANAGED_ACCOUNT_PATH) authentication_obj.sign_app_out() except Exception as e: show_error(e) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 89b99d1..3d76fda 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -1,45 +1,52 @@ """Unit tests for Main module""" -import sys -import os -sys.path.append("src") import unittest -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, call, patch from src import main -@patch('src.main.API_URL', "https://example.com/Beyondtrust/api/public/v3") -@patch('src.main.CLIENT_ID', "45612654321") -@patch('src.main.CLIENT_SECRET', "123321654") -@patch('src.main.SECRET_PATH', '{"path":"folder_name/title","output_id":"title"}') -@patch('src.main.MANAGED_ACCOUNT_PATH', '{"path":"system_name/managed_account_name","output_id":"managed_account_name"}') - +@patch("src.main.API_URL", "https://example.com/BeyondTrust/api/public/v3") +@patch("src.main.CLIENT_ID", "456126543212456126543212456126543212") +@patch("src.main.CLIENT_SECRET", "123321654234123321654234123321654234") +@patch("src.main.SECRET_PATH", '{"path":"folder_name/title","output_id":"title"}') +@patch( + "src.main.MANAGED_ACCOUNT_PATH", + '{"path":"system_name/managed_account_name","output_id":"managed_account_name"}', +) class TestMain(unittest.TestCase): """ Test for Main module """ - - @patch('src.main.append_output') - @patch('src.main.managed_account.ManagedAccount.get_secret') - @patch('src.main.secrets_safe.SecretsSafe.get_secret') - @patch('src.main.authentication.Authentication.get_api_access') - def test_main(self, get_api_access_mock, secrets_safe_get_secret_mock, managed_account_get_secret_mock, append_output_mock): + + @patch("src.main.append_output") + @patch("src.main.managed_account.ManagedAccount.get_secret") + @patch("src.main.secrets_safe.SecretsSafe.get_secret") + @patch("src.main.authentication.Authentication.get_api_access") + def test_main( + self, + get_api_access_mock, + secrets_safe_get_secret_mock, + managed_account_get_secret_mock, + append_output_mock, + ): """ Test main method, Success case """ - + mock = MagicMock() mock.status_code = 200 get_api_access_mock.return_value = mock - + secrets_safe_get_secret_mock.return_value = "test_secret" managed_account_get_secret_mock.return_value = "test_managed_account" append_output_mock.return_value = None - + main.main() - - main.append_output.assert_has_calls([ - call("title", "test_secret"), - call("managed_account_name", "test_managed_account"), - ]) + + main.append_output.assert_has_calls( + [ + call("title", "test_secret"), + call("managed_account_name", "test_managed_account"), + ] + )