pax_global_header00006660000000000000000000000064137744341230014522gustar00rootroot0000000000000052 comment=6875941ffa4fe73172d8b55d8bbeda8745a2aba9 pass-git-helper-1.1.1/000077500000000000000000000000001377443412300145265ustar00rootroot00000000000000pass-git-helper-1.1.1/.github/000077500000000000000000000000001377443412300160665ustar00rootroot00000000000000pass-git-helper-1.1.1/.github/workflows/000077500000000000000000000000001377443412300201235ustar00rootroot00000000000000pass-git-helper-1.1.1/.github/workflows/ci.yml000066400000000000000000000027011377443412300212410ustar00rootroot00000000000000name: CI build on: push: pull_request: schedule: - cron: '0 0 * * 0' jobs: lint-code: runs-on: ubuntu-latest steps: - name: Clone repo uses: actions/checkout@v2 - name: Set up Python 3.8 uses: actions/setup-python@v2 with: python-version: 3.8 - name: Install tox run: | python -m pip install --upgrade pip pip install tox - name: Lint with tox run: tox -e check lint-readme: runs-on: ubuntu-latest steps: - name: Clone repo uses: actions/checkout@v2 - name: Lint README.md uses: docker://avtodev/markdown-lint:v1 with: args: './README.md' test: runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: python-version: [3.7, 3.8, 3.9] steps: - name: Clone repo uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install Python dependencies run: | python -m pip install --upgrade pip pip install coverage tox tox-gh-actions - name: Test with tox run: | tox coverage xml --rcfile=setup.cfg - name: Publish coverage to codecov.io uses: codecov/codecov-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} pass-git-helper-1.1.1/.gitignore000066400000000000000000000001461377443412300165170ustar00rootroot00000000000000/build /dist *.egg-info /.tox/ /__pycache__/ /htmlcov/ /tags /.coverage /.python-version /.mypy_cache pass-git-helper-1.1.1/.markdownlintrc000066400000000000000000000001061377443412300175620ustar00rootroot00000000000000{ "default": true, "line_length": false, "MD041": false } pass-git-helper-1.1.1/LICENSE.txt000066400000000000000000000167431377443412300163640ustar00rootroot00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. pass-git-helper-1.1.1/README000077700000000000000000000000001377443412300166602README.mdustar00rootroot00000000000000pass-git-helper-1.1.1/README.md000066400000000000000000000232031377443412300160050ustar00rootroot00000000000000[![Actions Status](https://github.com/languitar/pass-git-helper/workflows/CI%20build/badge.svg)](https://github.com/languitar/pass-git-helper/actions) [![codecov](https://codecov.io/gh/languitar/pass-git-helper/branch/master/graph/badge.svg)](https://codecov.io/gh/languitar/pass-git-helper) # pass-git-helper [![Debian CI](https://badges.debian.net/badges/debian/testing/pass-git-helper/version.svg)](https://buildd.debian.org/pass-git-helper) [![AUR](https://img.shields.io/aur/version/pass-git-helper.svg)](https://aur.archlinux.org/packages/pass-git-helper/) [![Homebrew](https://img.shields.io/badge/dynamic/json.svg?url=https://formulae.brew.sh/api/formula/pass-git-helper.json&query=$.versions.stable&label=homebrew) A [git] credential helper implementation that allows using [pass] as the credential backend for your git repositories. This is achieved by explicitly defining mappings between hosts and entries in the password store. ## Preconditions It is recommended to configure GPG to use a graphical pinentry program. That way, you can also use this helper when [git] is invoked via GUI programs such as your IDE. For a configuration example, refer to the [ArchWiki](https://wiki.archlinux.org/index.php/GnuPG#pinentry). In case you really want to use the terminal for pinentry (via `pinentry-curses`), be sure to [appropriately configure the environment variable `GPG_TTY`](https://www.gnupg.org/documentation/manuals/gnupg/Invoking-GPG_002dAGENT.html), most likely by adding the following lines to your shell initialization: ```sh GPG_TTY=$(tty) export GPG_TTY ``` If you use this setup for remote work via SSH, also consider the alternative of [GPG agent forwarding](https://wiki.gnupg.org/AgentForwarding). ## Installation ### Official Packages If possible, use an available package for your Linux distribution or operating system such as the ones linked above via badges. ### From Source ```sh sudo pip install . ``` This might potentially install Python packages without the knowledge of your system's package manager. If all package preconditions are already met, you can also copy the script file to to your system to avoid this problem: ```sh sudo cp passgithelper.py /usr/local/bin/pass-git-helper ``` Another option is to install the script in an isolated [virtualenv](https://virtualenv.pypa.io/en/latest/): ```sh virtualenv /your/env /your/env/pip install . ``` ## Usage Create the file `~/.config/pass-git-helper/git-pass-mapping.ini`. This file uses ini syntax to specify the mapping of hosts to entries in the passwordstore database. Section headers define patterns which are matched against the host part of a URL with a git repository. Matching supports wildcards (using the python [fnmatch module](https://docs.python.org/3.7/library/fnmatch.html)). Each section needs to contain a `target` entry pointing to the entry in the password store with the password (and optionally username) to use. Example: ```ini [github.com*] target=dev/github [*.fooo-bar.*] target=dev/fooo-bar ``` To instruct git to use the helper, set the `credential.helper` configuration option of git to `/full/path/to/pass-git-helper`. In case you do not want to include a full path, a workaround using a shell fragment needs to be used, i.e. `!pass-git-helper $@` must be the option value. The option can be set using the CLI with: ```sh git config credential.helper '!pass-git-helper $@' ``` If you want to match entries not only based on the host, but also based on the path on a host, set `credential.useHttpPath` to `true` in your git config, e.g. via: ```sh git config credential.useHttpPath true ``` Afterwards, entries can be matched against `host.com/path/to/repo` in the mapping. This means that in order to use a specific account for a certain Github project, you can then use the following mapping pattern: ```ini [github.com/username/project*] target=dev/github ``` Please note that when including the path in the mapping, the mapping expressions need to match against the whole path. As a consequence, in case you want to use the same account for all Github projects, you need to make sure that a wildcard covers the path of the URL, as shown here: ```ini [github.com*] target=dev/github ``` The host can be used as a variable to address a pass entry. This is especially helpful for wildcard matches: ```ini [*] target=git-logins/${host} ``` The above configuration directive will lead to any host that did not match any previous section in the ini file to being looked up under the `git-logins` directory in your passwordstore. Using the `includeIf` directive available in git >= 2.13, it is also possible to perform matching based on the current working directory by invoking `pass-git-helper` with a conditional `MAPPING-FILE`. To achieve this, edit your `.gitconfig`, e.g. like this: ```ini [includeIf "gitdir:~/src/user1/"] path=~/.config/git/gitconfig_user1 [includeIf "gitdir:~/src/user2/"] path=~/.config/git/gitconfig_user2 ``` With the following contents of `gitconfig_user1` (and `gitconfig_user2` repspectively), `mapping_user1.ini`, which could contain a `target` entry to e.g. `github.com/user1` would always be invoked in `~/src/user1`: ```ini [user] name = user1 [credential] helper=/full/path/to/pass-git-helper -m /full/path/to/mapping_user1.ini ``` See also the offical [documentation](https://git-scm.com/docs/git-config#_includes) for `.gitconfig`. ### DEFAULT section Defaults suitable for all entries of the mapping file can be specified in a special section of the configuration file named `[DEFAULT]`. Everything configure in this section will automatically be available for all further entries in the file, but can be overriden there, too. ## Passwordstore Layout and Data Extraction ### Password As usual with [pass], this helper assumes that the password is contained in the first line of the passwordstore entry. Though uncommon, it is possible to strip a prefix from the data of the first line (such as `password:` by specifying an amount of characters to leave out in the `skip_password` field for an entry or also in the `[DEFAULT]` section to apply for all entries: ```ini [DEFAULT] # length of "password: " skip_password=10 [somedomain] # for some reasons, this entry doesn't have a password prefix skip_password=0 target=special/noprefix ``` ### Username `pass-git-helper` can also provide the username necessary for authenticating at a server. In contrast to the password, no clear convention exists how username information is stored in password entries. Therefore, multiple strategies to extract the username are implemented and can be selected globally for the whole passwordstore in the `[DEFAULT]` section, or individually for certain entries using the `username_extractor` key: ```ini [DEFAULT] username_extractor=regex_search regex_username=^user: (.*)$ [differingdomain.com] # use a fixed line here instead of a regex search username_extractor=specific_line line_username=1 ``` The following strategies can be configured: #### Strategy "specific_line" (default) Extracts the data from a line indexed by its line number. Optionally a fixed-length prefix can be stripped before returning the line contents. Configuration: * `line_username`: Line number containing the username, **0-based**. Default: 1 (second line) * `skip_username`: Number of characters to skip at the beginning of the line, for instance to skip a `user:` prefix. Similar to `skip_password`. Default: 0. #### Strategy "regex_search" Searches for the first line that matches a provided regular expressions and returns the contents of that line that are captured in a regular expression capture group. Configuration: * `regex_username`: The regular expression to apply. Has to contain a single capture group for indicating the data to extract. Default: `^username: +(.*)$`. #### Strategy "entry_name" Returns the last path fragment of the passwordstore entry as the username. For instance, if a regular [pass] call would be `pass show dev/github.com/languitar`, the returned username would be `languitar`. No configuration options. ### File Encoding By default, passwordstore entries are assumed to use UTF-8 encoding. If all or some of your entries use a different encoding, use the `encoding` key (for instance, in the `DEFAULT` section) to specify the used encoding. ## Command Line Options `-l` can be given as an option to the script to produce logging output on stderr. This might be useful to understand how the mapping is applied. `-m MAPPING_FILE` can be specified to use an alternative mapping file location. ## Skipping Processing In some automated contexts it might be necessary to prevent GPG from asking for the passphrase (via the agent). To achieve this, you can disable the complete processing of this helper by defining the environment variable `PASS_GIT_HELPER_SKIP` with any content (or no content at all). pass-git-helper will return immediately in this case, indicating to git that no suitable credentials could be found. ## License This library is [free software](https://en.wikipedia.org/wiki/Free_software); you can redistribute it and/or modify it under the terms of the [GNU Lesser General Public License](https://en.wikipedia.org/wiki/GNU_Lesser_General_Public_License) as published by the [Free Software Foundation](https://en.wikipedia.org/wiki/Free_Software_Foundation); either version 3 of the License, or any later version. This work is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU Lesser General Public License](https://www.gnu.org/copyleft/lgpl.html) for more details. [git]: https://git-scm.com/ [pass]: http://www.passwordstore.org/ "pass - the standard unix password manager" pass-git-helper-1.1.1/passgithelper.py000077500000000000000000000337071377443412300177670ustar00rootroot00000000000000#!/usr/bin/env python3 """ Implementation of the pass-git-helper utility. .. codeauthor:: Johannes Wienke """ import abc import argparse import configparser import fnmatch import logging import os import os.path import re import subprocess import sys from typing import Dict, IO, Mapping, Optional, Pattern, Sequence, Text import xdg.BaseDirectory LOGGER = logging.getLogger() CONFIG_FILE_NAME = "git-pass-mapping.ini" DEFAULT_CONFIG_FILE = os.path.join( xdg.BaseDirectory.save_config_path("pass-git-helper"), CONFIG_FILE_NAME ) def parse_arguments(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: """ Parse the command line arguments. Args: argv: If not ``None``, use the provided command line arguments for parsing. Otherwise, extract them automatically. Returns: The argparse object representing the parsed arguments. """ parser = argparse.ArgumentParser( description="Git credential helper using pass as the data source.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( "-m", "--mapping", type=argparse.FileType("r"), metavar="MAPPING_FILE", default=None, help="A mapping file to be used, specifying how hosts " "map to pass entries. Overrides the default mapping files from " "XDG config locations, usually: {config_file}".format( config_file=DEFAULT_CONFIG_FILE ), ) parser.add_argument( "-l", "--logging", action="store_true", default=False, help="Print debug messages on stderr. " "Might include sensitive information", ) parser.add_argument( "action", type=str, metavar="ACTION", help="Action to preform as specified in the git credential API", ) return parser.parse_args(argv) def parse_mapping(mapping_file: Optional[IO]) -> configparser.ConfigParser: """ Parse the file containing the mappings from hosts to pass entries. Args: mapping_file: Name of the file to parse. If ``None``, the default file from the XDG location is used. """ LOGGER.debug("Parsing mapping file. Command line: %s", mapping_file) def parse(mapping_file: IO) -> configparser.ConfigParser: config = configparser.ConfigParser() config.read_file(mapping_file) return config # give precedence to the user-specified file if mapping_file is not None: LOGGER.debug("Parsing command line mapping file") return parse(mapping_file) # fall back on XDG config location xdg_config_dir = xdg.BaseDirectory.load_first_config("pass-git-helper") if xdg_config_dir is None: raise RuntimeError( "No mapping configured so far at any XDG config location. " "Please create {config_file}".format(config_file=DEFAULT_CONFIG_FILE) ) default_file = os.path.join(xdg_config_dir, CONFIG_FILE_NAME) LOGGER.debug("Parsing mapping file %s", mapping_file) with open(default_file, "r") as file_handle: return parse(file_handle) def parse_request() -> Dict[str, str]: """ Parse the request of the git credential API from stdin. Returns: A dictionary with all key-value pairs of the request """ in_lines = sys.stdin.readlines() LOGGER.debug('Received request "%s"', in_lines) request = {} for line in in_lines: # skip empty lines to be a bit resilient against protocol errors if not line.strip(): continue parts = line.split("=", 1) assert len(parts) == 2 request[parts[0].strip()] = parts[1].strip() return request class DataExtractor(abc.ABC): """Interface for classes that extract values from pass entries.""" def __init__(self, option_suffix: Text = "") -> None: """ Create a new instance. Args: option_suffix: Suffix to put behind names of configuration keys for this instance. Subclasses must use this for their own options. """ self._option_suffix = option_suffix @abc.abstractmethod def configure(self, config: configparser.SectionProxy) -> None: """ Configure the extractor from the mapping section. Args: config: configuration section for the entry """ pass @abc.abstractmethod def get_value( self, entry_name: Text, entry_lines: Sequence[Text] ) -> Optional[Text]: """ Return the extracted value. Args: entry_name: Name of the pass entry the value shall be extracted from entry_lines: The entry contents as a sequence of text lines Returns: The extracted value or ``None`` if nothing applicable can be found in the entry. """ pass class SkippingDataExtractor(DataExtractor): """ Extracts data from a pass entry and optionally strips a prefix. The prefix is a fixed amount of characters. """ def __init__(self, prefix_length: int, option_suffix: Text = "") -> None: """ Create a new instance. Args: prefix_length: Amount of characters to skip at the beginning of the entry """ super().__init__(option_suffix) self._prefix_length = prefix_length @abc.abstractmethod def configure(self, config: configparser.SectionProxy) -> None: """Configure the amount of characters to skip.""" self._prefix_length = config.getint( "skip{suffix}".format(suffix=self._option_suffix), fallback=self._prefix_length, ) @abc.abstractmethod def _get_raw(self, entry_name: Text, entry_lines: Sequence[Text]) -> Optional[Text]: pass def get_value( self, entry_name: Text, entry_lines: Sequence[Text] ) -> Optional[Text]: """See base class method.""" raw_value = self._get_raw(entry_name, entry_lines) if raw_value is not None: return raw_value[self._prefix_length :] else: return None class SpecificLineExtractor(SkippingDataExtractor): """Extracts a specific line number from an entry.""" def __init__(self, line: int, prefix_length: int, option_suffix: Text = "") -> None: """ Create a new instance. Args: line: the line to extract, counting from zero prefix_length: Amount of characters to skip at the beginning of the line option_suffix: Suffix for each configuration option """ super().__init__(prefix_length, option_suffix) self._line = line def configure(self, config: configparser.SectionProxy) -> None: """See base class method.""" super().configure(config) self._line = config.getint( "line{suffix}".format(suffix=self._option_suffix), fallback=self._line ) def _get_raw(self, entry_name: Text, entry_lines: Sequence[Text]) -> Optional[Text]: if len(entry_lines) > self._line: return entry_lines[self._line] else: return None class RegexSearchExtractor(DataExtractor): """Extracts data using a regular expression with capture group.""" def __init__(self, regex: str, option_suffix: str) -> None: """ Create a new instance. Args: regex: The regular expression describing the entry line to match. The first matching line is selected. The expression must contain a single capture group that contains the data to return. option_suffix: Suffix for each configuration option """ super().__init__(option_suffix) self._regex = self._build_matcher(regex) def _build_matcher(self, regex: str) -> Pattern: matcher = re.compile(regex) if matcher.groups != 1: raise ValueError( 'Provided regex "{regex}" must contain a single ' "capture group for the value to return.".format(regex=regex) ) return matcher def configure(self, config: configparser.SectionProxy) -> None: """See base class method.""" super().configure(config) self._regex = self._build_matcher( config.get( "regex{suffix}".format(suffix=self._option_suffix), fallback=self._regex.pattern, ) ) def get_value( self, entry_name: Text, entry_lines: Sequence[Text] ) -> Optional[Text]: """See base class method.""" # Search through all lines and return the first matching one for line in entry_lines: match = self._regex.match(line) if match: return match.group(1) # nothing matched return None class EntryNameExtractor(DataExtractor): """Return the last path fragment of the pass entry as the desired value.""" def configure(self, config: configparser.SectionProxy) -> None: """Configure nothing.""" pass def get_value( self, entry_name: Text, entry_lines: Sequence[Text] ) -> Optional[Text]: """See base class method.""" return os.path.split(entry_name)[1] _line_extractor_name = "specific_line" _username_extractors = { _line_extractor_name: SpecificLineExtractor(1, 0, option_suffix="_username"), "regex_search": RegexSearchExtractor( r"^username: +(.*)$", option_suffix="_username" ), "entry_name": EntryNameExtractor(option_suffix="_username"), } def find_mapping_section( mapping: configparser.ConfigParser, request_header: str ) -> configparser.SectionProxy: """Select the mapping entry matching the request header.""" LOGGER.debug('Searching mapping to match against header "%s"', request_header) for section in mapping.sections(): if fnmatch.fnmatch(request_header, section): LOGGER.debug( 'Section "%s" matches requested header "%s"', section, request_header ) return mapping[section] raise ValueError( f"No mapping section in {mapping.sections()} matches request {request_header}" ) def get_request_section_header(request: Mapping[str, str]) -> str: """Return the canonical host + optional path for section header matching.""" if "host" not in request: LOGGER.error("host= entry missing in request. Cannot query without a host") raise ValueError("Request lacks host entry") host = request["host"] if "path" in request: host = "/".join([host, request["path"]]) return host def define_pass_target( section: configparser.SectionProxy, request: Mapping[str, str] ) -> str: """Determine the pass target by filling in potentially used variables.""" pass_target = section.get("target").replace("${host}", request["host"]) if "username" in request: pass_target = pass_target.replace("${username}", request["username"]) return pass_target def get_password( request: Mapping[str, str], mapping: configparser.ConfigParser ) -> None: """ Resolve the given credential request in the provided mapping definition. The result is printed automatically. Args: request: The credential request specified as a dict of key-value pairs. mapping: The mapping configuration as a ConfigParser instance. """ LOGGER.debug('Received request "%s"', request) header = get_request_section_header(request) section = find_mapping_section(mapping, header) pass_target = define_pass_target(section, request) password_extractor = SpecificLineExtractor(0, 0, option_suffix="_password") password_extractor.configure(section) username_extractor = _username_extractors[ section.get("username_extractor", fallback=_line_extractor_name) ] username_extractor.configure(section) LOGGER.debug('Requesting entry "%s" from pass', pass_target) # silence the subprocess injection warnings as it is the user's # responsibility to provide a safe mapping and execution environment output = subprocess.check_output( # noqa: S603, S607 ["pass", "show", pass_target] ).decode(section.get("encoding", "UTF-8")) lines = output.splitlines() password = password_extractor.get_value(pass_target, lines) username = username_extractor.get_value(pass_target, lines) if password: print("password={password}".format(password=password)) # noqa: T001 if "username" not in request and username: print("username={username}".format(username=username)) # noqa: T001 def handle_skip() -> None: """Terminate the process if skipping is requested via an env variable.""" if "PASS_GIT_HELPER_SKIP" in os.environ: LOGGER.info("Skipping processing as requested via environment variable") sys.exit(1) def main(argv: Optional[Sequence[str]] = None) -> None: """ Start the pass-git-helper script. Args: argv: If not ``None``, use the provided command line arguments for parsing. Otherwise, extract them automatically. """ args = parse_arguments(argv=argv) if args.logging: logging.basicConfig(level=logging.DEBUG) handle_skip() action = args.action request = parse_request() LOGGER.debug("Received action %s with request:\n%s", action, request) try: mapping = parse_mapping(args.mapping) except Exception as error: LOGGER.critical("Unable to parse mapping file", exc_info=True) print( # noqa: T001 "Unable to parse mapping file: {error}".format(error=error), file=sys.stderr ) sys.exit(1) if action == "get": get_password(request, mapping) else: LOGGER.info("Action %s is currently not supported", action) sys.exit(1) if __name__ == "__main__": main() pass-git-helper-1.1.1/setup.cfg000066400000000000000000000015701377443412300163520ustar00rootroot00000000000000[tool:pytest] log_level = DEBUG addopts = --cov-config=setup.cfg [flake8] exclude = doc, .tox, .git, __pycache__, build, dist, .eggs, env, .mypy_cache ignore = ANN101, ANN102, D202, D413, E203, S101, S404, TYP101, TYP102, TYP002, TYP003, W503, per-file-ignores = test_*: D1, S105 setup.py: D1 application-import-names = passgithelper import-order-style = google max-line-length = 88 [coverage:run] branch = True source = passgithelper [coverage:paths] source = . */site-packages/ [coverage:report] exclude_lines = pragma: no cover def __repr__ if __name__ == "__main__": @abc.abstractmethod [mypy] ignore_missing_imports=True disallow_untyped_defs = True check_untyped_defs = True no_implicit_optional = True warn_unused_configs = True warn_unused_ignores = True pass-git-helper-1.1.1/setup.py000066400000000000000000000015231377443412300162410ustar00rootroot00000000000000from setuptools import setup setup( name="pass-git-helper", version="1.1.1", install_requires=["pyxdg"], extras_require={"test": ["pytest", "pytest-coverage", "pytest-mock"]}, py_modules=["passgithelper"], entry_points={"console_scripts": ["pass-git-helper = passgithelper:main"]}, author="Johannes Wienke", author_email="languitar@semipol.de", url="https://github.com/languitar/pass-git-helper", description="A git credential helper interfacing with pass, " "the standard unix password manager.", license="LGPLv3+", keywords=["git", "passwords", "pass", "credentials", "password store"], classifiers=[ "Programming Language :: Python :: 3", "Topic :: Utilities", "License :: OSI Approved :: " "GNU Lesser General Public License v3 or later (LGPLv3+)", ], ) pass-git-helper-1.1.1/test_data/000077500000000000000000000000001377443412300164765ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/entry-name-extraction/000077500000000000000000000000001377443412300227335ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/entry-name-extraction/git-pass-mapping.ini000066400000000000000000000001041377443412300266070ustar00rootroot00000000000000[mytest.com] username_extractor=entry_name target=dev/mytest/myuser pass-git-helper-1.1.1/test_data/regex-extraction/000077500000000000000000000000001377443412300217665ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/regex-extraction/git-pass-mapping.ini000066400000000000000000000001351377443412300256460ustar00rootroot00000000000000[mytest.com] username_extractor=regex_search regex_username=^myuser: (.*)$ target=dev/mytest pass-git-helper-1.1.1/test_data/smoke/000077500000000000000000000000001377443412300176145ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/smoke/git-pass-mapping.ini000066400000000000000000000000371377443412300234750ustar00rootroot00000000000000[mytest.com] target=dev/mytest pass-git-helper-1.1.1/test_data/unknown-username-extractor/000077500000000000000000000000001377443412300240235ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/unknown-username-extractor/git-pass-mapping.ini000066400000000000000000000001111377443412300276750ustar00rootroot00000000000000[DEFAULT] username_extractor=doesntexist [mytest.com] target=dev/mytest pass-git-helper-1.1.1/test_data/wildcard/000077500000000000000000000000001377443412300202675ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/wildcard/git-pass-mapping.ini000066400000000000000000000000431377443412300241450ustar00rootroot00000000000000[*] target=dev/${host}/${username} pass-git-helper-1.1.1/test_data/with-encoding/000077500000000000000000000000001377443412300212355ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/with-encoding/git-pass-mapping.ini000066400000000000000000000000721377443412300251150ustar00rootroot00000000000000[DEFAULT] encoding=LATIN1 [mytest.com] target=dev/mytest pass-git-helper-1.1.1/test_data/with-path/000077500000000000000000000000001377443412300204035ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/with-path/git-pass-mapping.ini000066400000000000000000000001241377443412300242610ustar00rootroot00000000000000[mytest.com/notthis/*] target=dev/notthis [mytest.com/subpath/*] target=dev/mytest pass-git-helper-1.1.1/test_data/with-username-skip/000077500000000000000000000000001377443412300222325ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/with-username-skip/git-pass-mapping.ini000066400000000000000000000001001377443412300261020ustar00rootroot00000000000000[mytest.com] target=dev/mytest skip_username=6 skip_password=10 pass-git-helper-1.1.1/test_data/with-username/000077500000000000000000000000001377443412300212665ustar00rootroot00000000000000pass-git-helper-1.1.1/test_data/with-username/git-pass-mapping.ini000066400000000000000000000000451377443412300251460ustar00rootroot00000000000000[plainline.com] target=dev/plainline pass-git-helper-1.1.1/test_passgithelper.py000066400000000000000000000304271377443412300210170ustar00rootroot00000000000000import configparser from dataclasses import dataclass import io from typing import Any, Iterable, Optional, Sequence, Text import pytest from pytest_mock import MockFixture import passgithelper @dataclass class HelperConfig: xdg_dir: Optional[str] request: str entry_data: bytes entry_name: Optional[str] = None @pytest.fixture() def _helper_config(mocker: MockFixture, request: Any) -> Iterable[None]: xdg_mock = mocker.patch("xdg.BaseDirectory.load_first_config") xdg_mock.return_value = request.param.xdg_dir mocker.patch("sys.stdin.readlines").return_value = io.StringIO( request.param.request ) subprocess_mock = mocker.patch("subprocess.check_output") subprocess_mock.return_value = request.param.entry_data yield if request.param.entry_name is not None: subprocess_mock.assert_called_once() subprocess_mock.assert_called_with(["pass", "show", request.param.entry_name]) def test_handle_skip_nothing(monkeypatch: Any) -> None: monkeypatch.delenv("PASS_GIT_HELPER_SKIP", raising=False) passgithelper.handle_skip() # should do nothing normally def test_handle_skip_exits(monkeypatch: Any) -> None: monkeypatch.setenv("PASS_GIT_HELPER_SKIP", "1") with pytest.raises(SystemExit): passgithelper.handle_skip() class TestSkippingDataExtractor: class ExtractorImplementation(passgithelper.SkippingDataExtractor): def configure(self, config: configparser.SectionProxy) -> None: pass def __init__(self, skip_characters: int = 0) -> None: super().__init__(skip_characters) def _get_raw( self, entry_text: Text, entry_lines: Sequence[Text] ) -> Optional[Text]: return entry_lines[0] def test_smoke(self) -> None: extractor = self.ExtractorImplementation(4) assert extractor.get_value("foo", ["testthis"]) == "this" def test_too_short(self) -> None: extractor = self.ExtractorImplementation(8) assert extractor.get_value("foo", ["testthis"]) == "" extractor = self.ExtractorImplementation(10) assert extractor.get_value("foo", ["testthis"]) == "" class TestSpecificLineExtractor: def test_smoke(self) -> None: extractor = passgithelper.SpecificLineExtractor(1, 6) assert ( extractor.get_value("foo", ["line 1", "user: bar", "more lines"]) == "bar" ) def test_no_such_line(self) -> None: extractor = passgithelper.SpecificLineExtractor(3, 6) assert extractor.get_value("foo", ["line 1", "user: bar", "more lines"]) is None class TestRegexSearchExtractor: def test_smoke(self) -> None: extractor = passgithelper.RegexSearchExtractor("^username: (.*)$", "") assert ( extractor.get_value( "foo", [ "thepassword", "somethingelse", "username: user", "username: second ignored", ], ) == "user" ) def test_missing_group(self) -> None: with pytest.raises(ValueError, match="must contain"): passgithelper.RegexSearchExtractor("^username: .*$", "") def test_configuration(self) -> None: extractor = passgithelper.RegexSearchExtractor("^username: (.*)$", "_username") config = configparser.ConfigParser() config.read_string( r"""[test] regex_username=^foo: (.*)$""" ) extractor.configure(config["test"]) assert extractor._regex.pattern == r"^foo: (.*)$" def test_configuration_checks_groups(self) -> None: extractor = passgithelper.RegexSearchExtractor("^username: (.*)$", "_username") config = configparser.ConfigParser() config.read_string( r"""[test] regex_username=^foo: .*$""" ) with pytest.raises(ValueError, match="must contain"): extractor.configure(config["test"]) class TestEntryNameExtractor: def test_smoke(self) -> None: assert passgithelper.EntryNameExtractor().get_value("foo/bar", []) == "bar" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( None, "", b"ignored", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_parse_mapping_file_missing() -> None: with pytest.raises(RuntimeError): passgithelper.parse_mapping(None) @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/smoke", "", b"ignored", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_parse_mapping_from_xdg() -> None: config = passgithelper.parse_mapping(None) assert "mytest.com" in config assert config["mytest.com"]["target"] == "dev/mytest" class TestScript: def test_help(self, capsys: Any) -> None: with pytest.raises(SystemExit): passgithelper.main(["--help"]) assert "usage: " in capsys.readouterr().out def test_skip(self, monkeypatch: Any, capsys: Any) -> None: monkeypatch.setenv("PASS_GIT_HELPER_SKIP", "1") with pytest.raises(SystemExit): passgithelper.main(["get"]) out, err = capsys.readouterr() assert not out assert not err @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/smoke", """ protocol=https host=mytest.com""", b"narf", "dev/mytest", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_smoke_resolve(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=narf\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/smoke", """ protocol=https host=mytest.com path=/foo/bar.git""", b"ignored", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_path_used_if_present_fails(self) -> None: with pytest.raises(ValueError, match="No mapping section"): passgithelper.main(["get"]) @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/with-path", """ protocol=https host=mytest.com path=subpath/bar.git""", b"narf", "dev/mytest", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_path_used_if_present(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=narf\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/wildcard", """ protocol=https host=wildcard.com username=wildcard path=subpath/bar.git""", b"narf-wildcard", "dev/wildcard.com/wildcard", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_wildcard_matching(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=narf-wildcard\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/with-username", """ host=plainline.com""", b"password\nusername", "dev/plainline", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_username_provided(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=password\nusername=username\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/with-username", """ host=plainline.com username=narf""", b"password\nusername", "dev/plainline", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_username_skipped_if_provided(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=password\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/with-username", """ protocol=https host=mytest.com""", b"narf", "dev/mytest", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_custom_mapping_used(self, capsys: Any) -> None: # this would fail for the default file from with-username passgithelper.main(["-m", "test_data/smoke/git-pass-mapping.ini", "get"]) out, _ = capsys.readouterr() assert out == "password=narf\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/with-username-skip", """ protocol=https host=mytest.com""", b"password: xyz\nuser: tester", "dev/mytest", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_prefix_skipping(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=xyz\nusername=tester\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/unknown-username-extractor", """ protocol=https host=mytest.com""", b"ignored", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_select_unknown_extractor(self) -> None: with pytest.raises(KeyError): passgithelper.main(["get"]) @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/regex-extraction", """ protocol=https host=mytest.com""", b"xyz\nsomeline\nmyuser: tester\n" b"morestuff\nmyuser: ignore", "dev/mytest", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_regex_username_selection(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=xyz\nusername=tester\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/entry-name-extraction", """ protocol=https host=mytest.com""", b"xyz", "dev/mytest/myuser", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_entry_name_is_user(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=xyz\nusername=myuser\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/with-encoding", """ protocol=https host=mytest.com""", "täßt".encode("LATIN1"), "dev/mytest", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_uses_configured_encoding(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == f"password=täßt\n" @pytest.mark.parametrize( "_helper_config", [ HelperConfig( "test_data/smoke", """ protocol=https host=mytest.com""", "täßt".encode("UTF-8"), "dev/mytest", ), ], indirect=True, ) @pytest.mark.usefixtures("_helper_config") def test_uses_utf8_by_default(self, capsys: Any) -> None: passgithelper.main(["get"]) out, _ = capsys.readouterr() assert out == "password=täßt\n" pass-git-helper-1.1.1/tox.ini000066400000000000000000000031061377443412300160410ustar00rootroot00000000000000[tox] envlist = coverage-clean,test-py{37,38,39}, check, coverage [testenv] extras = test setenv = COVERAGE_FILE = ./.coverage.{envname} commands = {envbindir}/python -V {envbindir}/pytest --cov {posargs} {envbindir}/coverage report depends = coverage-clean [testenv:coverage-clean] deps = coverage skip_install = true commands = coverage erase depends = [testenv:coverage] depends = test-py{37,38} deps = coverage skip_install = true setenv = commands = - coverage combine {envbindir}/coverage html {envbindir}/coverage report [testenv:check] deps = pydocstyle~=5.0.0 flake8~=3.7.0 dlint~=0.10.0 flake8-annotations~=2.3.0 flake8-bandit~=2.1.0 flake8-black~=0.2.1 flake8-bugbear~=20.1.4 flake8-builtins~=1.5.3 flake8-cognitive-complexity~=0.1.0 flake8-comprehensions~=3.2.3 flake8-debugger~=3.2.1 flake8-docstrings~=1.5.0 flake8-eradicate~=0.4.0 flake8-expression-complexity~=0.0.6 flake8-import-order~=0.18.1 flake8-junit-report~=2.1.0 flake8-logging-format~=0.6.0 flake8-mock~=0.3 flake8-mutable~=1.2.0 flake8-pep3101~=1.3.0 flake8-pie~=0.5.0 flake8-print~=3.1.4 flake8-pytest-style~=1.2.3 flake8-simplify~=0.10.0 flake8-string-format~=0.3.0 flake8-tidy-imports~=4.1.0 flake8-variables-names~=0.0.3 pep8-naming~=0.11.1 mypy==0.790 black==20.8b0 commands = {envbindir}/python -V {envbindir}/flake8 {posargs} {envbindir}/mypy passgithelper.py [gh-actions] python = 3.7: py37, coverage 3.8: py38, coverage 3.9: py39, coverage