#!/usr/bin/env python3 import csv import logging import os import re import pathlib from argparse import Action, ArgumentParser, FileType import gnupg logger = logging.getLogger(__name__) class CSVExporter: def __init__(self, kpx_format, login_fields, get_url, exclude_rows): logging.basicConfig(level=logging.INFO) self.logger = logger # Set to True to allow for alternate password csv to be created # See README for differences self.kpx_format = kpx_format if self.kpx_format: # A list of possible fields (in order) that could be converted to # login fields self.login_fields = login_fields or [] # Set to True to extract url fields self.get_url = get_url # A regular expression list of lines that should be excluded from # the notes field self.exclude_rows = exclude_rows or [] self.logger.info("Using KPX format: %s", self.kpx_format) def traverse(self, path): for root, dirs, files in os.walk(path): if '.git' in dirs: dirs.remove('.git') for name in files: yield os.path.join(root, name) def get_metadata(self, notes_raw): lines = notes_raw.split('\n') # A list of lines to keep as notes (will be joined by newline) notes = [] # The extracted user field user = '' # The extracted URL field url = '' # This will extract each field name (for example, if a line in notes # was `user: user1`, fields should contain 'user') all_fields = set() for line in lines: field_search = re.search('^(.*) ?: ?.*$', line, re.I) if field_search: all_fields.add(field_search.group(1)) # Check if any of the fields match the login names login_fields = [ field for field in self.login_fields if field in all_fields ] # Get the field to use for the login. Since self.login_fields is in order, # the 0th element will contain the first match login_field = None if not login_fields else login_fields[0] # Iterate through the file again to build the return array for line in lines: # If any of the exclusion patterns match, ignore the line if [pattern for pattern in self.exclude_rows if re.search(pattern, line, re.I)]: continue if login_field: user_search = re.search( '^' + login_field + ' ?: ?(.*)$', line, re.I) if user_search: user = user_search.group(1) # The user was matched, don't add it to notes continue if self.get_url: url_search = re.search('^url ?: ?(.*)$', line, re.I) if url_search: url = url_search.group(1) # The url was matched, don't add it to notes continue notes.append(line) return (user, url, '\n'.join(notes).strip()) def parse(self, basepath, path, data): p = pathlib.Path(path) name = p.stem group = os.path.dirname(os.path.os.path.relpath(path, basepath)) if not group: try: parts = list(p.parts[:-1]) i = parts.index('.password-store') group = os.path.join(*parts[i+1:]) except ValueError: pass split_data = data.split('\n', maxsplit=1) password = split_data[0] # Perform if/else in case there are no notes for a field notes = split_data[1] if len(split_data) > 1 else "" self.logger.info("Processing %s", name) if self.kpx_format: # We are using the advanced format; try extracting user and url user, url, notes = self.get_metadata(notes) return [group, name, user, password, url, notes] else: # We are not using KPX format; just use notes return [group, name, password, notes] def main(gpgbinary, use_agent, pass_path, kpx_format, login_fields, get_url, exclude_rows, outfile): exporter = CSVExporter(kpx_format, login_fields, get_url, exclude_rows) gpg = gnupg.GPG(use_agent=use_agent, gpgbinary=gpgbinary) gpg.encoding = 'utf-8' csv_data = [] for file_path in exporter.traverse(pass_path): if os.path.splitext(file_path)[1] == '.gpg': with open(file_path, 'rb') as f: data = str(gpg.decrypt_file(f)) if len(data) == 0: logger.warning("Could not decrypt %s or it is empty.", file_path) csv_data.append(exporter.parse(pass_path, file_path, data)) writer = csv.writer(outfile, delimiter=',') writer.writerows(csv_data) outfile.close() class ExtendAction(Action): # Python 3.8 has 'extend' built in. def __call__(self, parser, namespace, values, option_string=None): items = getattr(namespace, self.dest) or [] items.extend(values) setattr(namespace, self.dest, items) class OptionsParser(ArgumentParser): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.add_argument( 'pass_path', metavar='path', type=str, help="path to the password-store folder to export", ) self.add_argument( '-a', '--agent', action='store_true', help="ask gpg to use its auth agent", dest='use_agent', ) self.add_argument( '-b', '--gpgbinary', type=str, help="path to the gpg binary you wish to use", dest='gpgbinary', default="gpg" ) self.add_argument( '-o', '--outfile', type=FileType('w'), help="Store to an output file", dest='outfile', default="-" ) self.add_argument( '-x', '--kpx', action='store_true', help="format the CSV for KeePassXC", dest='kpx_format', ) self.add_argument( '-l', '--login-fields', action=ExtendAction, nargs='+', type=str, help="strings to interpret as names of login fields (only used with -x)" ) self.add_argument( '-u', '--get-url', action='store_true', help="match row starting with 'url:' and extract it (only used with -x)" ) self.add_argument( '-e', '--exclude-rows', action=ExtendAction, nargs='+', type=str, help="regexps to exclude from the notes field (only used with -x)" ) if __name__ == '__main__': PARSER = OptionsParser() ARGS = PARSER.parse_args() main(**vars(ARGS))