diff --git a/docs/cli-usage.rst b/docs/cli-usage.rst index 1c24824c8..c1b59bfef 100644 --- a/docs/cli-usage.rst +++ b/docs/cli-usage.rst @@ -48,7 +48,7 @@ example: [elsewhere] url = http://else.whe.re:8080 - private_token = CkqsjqcQSFH5FQKDccu4 + private_token = helper: path/to/helper.sh timeout = 1 The ``default`` option of the ``[global]`` section defines the GitLab server to @@ -93,6 +93,8 @@ Only one of ``private_token``, ``oauth_token`` or ``job_token`` should be defined. If neither are defined an anonymous request will be sent to the Gitlab server, with very limited permissions. +We recommend that you use `Credential helpers`_ to securely store your tokens. + .. list-table:: GitLab server options :header-rows: 1 @@ -119,6 +121,56 @@ server, with very limited permissions. * - ``http_password`` - Password for optional HTTP authentication + +Credential helpers +------------------ + +For all configuration options that contain secrets (``http_password``, +``personal_token``, ``oauth_token``, ``job_token``), you can specify +a helper program to retrieve the secret indicated by a ``helper:`` +prefix. This allows you to fetch values from a local keyring store +or cloud-hosted vaults such as Bitwarden. Environment variables are +expanded if they exist and ``~`` expands to your home directory. + +It is expected that the helper program prints the secret to standard output. +To use shell features such as piping to retrieve the value, you will need +to use a wrapper script; see below. + +Example for a `keyring `_ helper: + +.. code-block:: ini + + [global] + default = somewhere + ssl_verify = true + timeout = 5 + + [somewhere] + url = http://somewhe.re + private_token = helper: keyring get Service Username + timeout = 1 + +Example for a `pass `_ helper with a wrapper script: + +.. code-block:: ini + + [global] + default = somewhere + ssl_verify = true + timeout = 5 + + [somewhere] + url = http://somewhe.re + private_token = helper: /path/to/helper.sh + timeout = 1 + +In `/path/to/helper.sh`: + +.. code-block:: bash + + #!/bin/bash + pass show path/to/password | head -n 1 + CLI === diff --git a/gitlab/config.py b/gitlab/config.py index 710a35459..c663bf841 100644 --- a/gitlab/config.py +++ b/gitlab/config.py @@ -17,7 +17,10 @@ import os import configparser +import shlex +import subprocess from typing import List, Optional, Union +from os.path import expanduser, expandvars from gitlab.const import USER_AGENT @@ -33,6 +36,10 @@ def _env_config() -> List[str]: os.path.expanduser("~/.python-gitlab.cfg"), ] +HELPER_PREFIX = "helper:" + +HELPER_ATTRIBUTES = ["job_token", "http_password", "private_token", "oauth_token"] + class ConfigError(Exception): pass @@ -50,6 +57,10 @@ class GitlabConfigMissingError(ConfigError): pass +class GitlabConfigHelperError(ConfigError): + pass + + class GitlabConfigParser(object): def __init__( self, gitlab_id: Optional[str] = None, config_files: Optional[List[str]] = None @@ -150,6 +161,8 @@ def __init__( except Exception: pass + self._get_values_from_helper() + self.api_version = "4" try: self.api_version = self._config.get("global", "api_version") @@ -192,3 +205,31 @@ def __init__( self.user_agent = self._config.get(self.gitlab_id, "user_agent") except Exception: pass + + def _get_values_from_helper(self): + """Update attributes that may get values from an external helper program""" + for attr in HELPER_ATTRIBUTES: + value = getattr(self, attr) + if not isinstance(value, str): + continue + + if not value.lower().strip().startswith(HELPER_PREFIX): + continue + + helper = value[len(HELPER_PREFIX) :].strip() + commmand = [expanduser(expandvars(token)) for token in shlex.split(helper)] + + try: + value = ( + subprocess.check_output(commmand, stderr=subprocess.PIPE) + .decode("utf-8") + .strip() + ) + except subprocess.CalledProcessError as e: + stderr = e.stderr.decode().strip() + raise GitlabConfigHelperError( + f"Failed to read {attr} value from helper " + f"for {self.gitlab_id}:\n{stderr}" + ) from e + + setattr(self, attr, value) diff --git a/gitlab/tests/test_config.py b/gitlab/tests/test_config.py index 7a9e23954..58ccbb0cc 100644 --- a/gitlab/tests/test_config.py +++ b/gitlab/tests/test_config.py @@ -17,6 +17,7 @@ import os import unittest +from textwrap import dedent import mock import io @@ -193,6 +194,43 @@ def test_valid_data(m_open, path_exists): assert True == cp.ssl_verify +@mock.patch("os.path.exists") +@mock.patch("builtins.open") +def test_data_from_helper(m_open, path_exists, tmp_path): + helper = tmp_path / "helper.sh" + helper.write_text( + dedent( + """\ + #!/bin/sh + echo "secret" + """ + ) + ) + helper.chmod(0o755) + + fd = io.StringIO( + dedent( + """\ + [global] + default = helper + + [helper] + url = https://helper.url + oauth_token = helper: %s + """ + ) + % helper + ) + + fd.close = mock.Mock(return_value=None) + m_open.return_value = fd + cp = config.GitlabConfigParser(gitlab_id="helper") + assert "helper" == cp.gitlab_id + assert "https://helper.url" == cp.url + assert None == cp.private_token + assert "secret" == cp.oauth_token + + @mock.patch("os.path.exists") @mock.patch("builtins.open") @pytest.mark.parametrize(