From 73b15ec6811e3fb70a7b78f9dc0ca889ec256abf Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Thu, 28 Mar 2024 08:42:32 +0100 Subject: [PATCH] --extra-fields --- docs/src/en/index.md | 7 +++- docs/src/ru/index.md | 7 +++- src/bitwarden_import_msecure/main.py | 49 ++++++++++++++++------ tests/conftest.py | 5 +++ tests/resources/bitwarden_export.csv | 6 +-- tests/resources/bitwarden_notes_export.csv | 10 +++++ tests/test_bitwarden_import_msecure.py | 23 ++++++++-- 7 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 tests/resources/bitwarden_notes_export.csv diff --git a/docs/src/en/index.md b/docs/src/en/index.md index 71fa1b9..32574d9 100644 --- a/docs/src/en/index.md +++ b/docs/src/en/index.md @@ -1,6 +1,11 @@ # bitwarden-import-msecure -Migration from mSecure to Bitwarden +Migration from mSecure to Bitwarden. + +Unlike the built-in Bitwarden import tool, this script does not place each secret into a separate folder. +Instead, it organizes secrets into meaningful folders and offers several options to customize the import process. + +Additionally, this simple Python script can be easily modified to meet your specific needs. ## Installation diff --git a/docs/src/ru/index.md b/docs/src/ru/index.md index ff9cea7..8771789 100644 --- a/docs/src/ru/index.md +++ b/docs/src/ru/index.md @@ -1,6 +1,11 @@ # bitwarden-import-msecure -Переход с mSecure на Bitwarden +Переход с mSecure на Bitwarden. + +В отличие от встроенного инструмента импорта Bitwarden, этот скрипт не помещает каждый секрет в отдельную папку. +Вместо этого он организует секреты в папки по смыслу и предлагает несколько опций для настройки процесса импорта. + +Кроме того, этот простой скрипт на Python может быть легко изменен для удовлетворения ваших конкретных потребностей. ## Установка diff --git a/src/bitwarden_import_msecure/main.py b/src/bitwarden_import_msecure/main.py index abe3e7e..720ea78 100644 --- a/src/bitwarden_import_msecure/main.py +++ b/src/bitwarden_import_msecure/main.py @@ -3,18 +3,30 @@ import os import csv from pathlib import Path -from typing import List +from typing import List, Dict, Tuple import rich_click as click OUTPUT_FILE_DEFAULT = "bitwarden.csv" +NOTES_MODE = "notes" @click.command() @click.argument("input_file", type=click.Path(exists=True)) # ~/Downloads/mSecure Export File.csv @click.argument("output_file", type=click.Path(), required=False) @click.option("--force", is_flag=True, help="Overwrite the output file if it exists.") -def bitwarden_import_msecure(input_file: str, output_file: str, force: bool) -> None: +@click.option( + "--extra-fields", + type=click.Choice(["custom-fields", NOTES_MODE]), + default="custom-fields", + help=( + "How to handle mSecure fields that don't match Bitwarden fields." + f"By default, they are added as custom fields. Use '{NOTES_MODE}' to add them to notes." + ), +) +def bitwarden_import_msecure( + input_file: str, output_file: str, force: bool, extra_fields: str +) -> None: """ Converts file `INPUT_FILE` exported from mSecure to Bitwarden compatible format to `OUTPUT_FILE`. @@ -55,12 +67,12 @@ def bitwarden_import_msecure(input_file: str, output_file: str, force: bool) -> for row in reader: if row and not row[0].startswith("mSecure"): - writer.writerow(convert_row(row)) + writer.writerow(convert_row(row, extra_fields == NOTES_MODE)) click.echo(f"Bitwarden CSV saved to {output_file}") -def convert_row(row: List[str]) -> List[str]: +def convert_row(row: List[str], extra_fields_to_notes: bool) -> List[str]: """Convert mSecure row to Bitwarden row.""" name = row[0].split("|")[0] if len(row[0].split("|")) > 2: @@ -80,6 +92,7 @@ def convert_row(row: List[str]) -> List[str]: # "Name on Card": "", # "Expiration Date": "", } + fields = {} for field in row[4:]: parts = field.split("|") if parts[0] in field_values: @@ -87,20 +100,19 @@ def convert_row(row: List[str]) -> List[str]: print(f"Warning: Duplicate field `{parts[0]}` in row `{row}`.") field_values[parts[0]] = "|".join(parts[2:]) elif any(value.strip() for value in parts[2:]): - notes += f"\n{parts[0]}: {','.join(parts[2:])}" - username = field_values["Card Number"] or field_values["Username"] - password = field_values["Security Code"] or field_values["Password"] - if field_values["Card Number"] and field_values["Username"]: - click.echo(f"Error: Both Card Number and Username present in row:\n{row}") - if field_values["Security Code"] and field_values["Password"]: - click.echo(f"Error: Both Security Code and Password present in row:\n{row}") + if extra_fields_to_notes: + notes += f"\n{parts[0]}: {','.join(parts[2:])}" + else: + fields[parts[0]] = ",".join(parts[2:]) + password, username = get_creds(field_values, row) if field_values["Card Number"]: if tag: click.echo(f"Warning: Tag `{tag}` present for Card, override with `card`:\n{row}") tag = "card" if not username and not password and not field_values["Website"]: record_type = "note" - fields = f"PIN: {field_values['PIN']}" if field_values["PIN"] else "" # todo: set hidden type + if field_values["PIN"]: + fields["PIN"] = field_values["PIN"] return [ tag, # folder @@ -108,7 +120,7 @@ def convert_row(row: List[str]) -> List[str]: record_type, # type name, # name notes, # notes - fields, # fields + "\n".join([f"{name}: {value}" for name, value in fields.items()]), # fields "", # reprompt field_values["Website"], # login_uri username, # login_username @@ -117,5 +129,16 @@ def convert_row(row: List[str]) -> List[str]: ] +def get_creds(field_values: Dict[str, str], row: List[str]) -> Tuple[str, str]: + """Get username and password.""" + username = field_values["Card Number"] or field_values["Username"] + password = field_values["Security Code"] or field_values["Password"] + if field_values["Card Number"] and field_values["Username"]: + click.echo(f"Error: Both Card Number and Username present in row:\n{row}") + if field_values["Security Code"] and field_values["Password"]: + click.echo(f"Error: Both Security Code and Password present in row:\n{row}") + return password, username + + if __name__ == "__main__": # pragma: no cover bitwarden_import_msecure() # pylint: disable=no-value-for-parameter diff --git a/tests/conftest.py b/tests/conftest.py index 5a6f88b..8cf5e20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,3 +24,8 @@ def msecure_export(): @pytest.fixture def bitwarden_file(): return RESOURCES / "bitwarden_export.csv" + + +@pytest.fixture +def bitwarden_notes_file(): + return RESOURCES / "bitwarden_notes_export.csv" \ No newline at end of file diff --git a/tests/resources/bitwarden_export.csv b/tests/resources/bitwarden_export.csv index 09c01cb..f62ddc8 100644 --- a/tests/resources/bitwarden_export.csv +++ b/tests/resources/bitwarden_export.csv @@ -3,8 +3,8 @@ folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_p ,,login, VK,,,,vk.com,+1 555 555 5555,, ,,login,(deleted) airtable sheets,,,,https://airtable.com/,andrey@sorokin.engineer,my-cool-password, card,,login,My VISA,"+1 555 555 5555 -11111, London, Baker Street 221B. -Expiration Date: 12/2099 -Name on Card: Sherlock Holmes",PIN: 1234,,,1234 5678 9012 3456,123, +11111, London, Baker Street 221B.","Expiration Date: 12/2099 +Name on Card: Sherlock Holmes +PIN: 1234",,,1234 5678 9012 3456,123, ,,login,Docker hub,"andrey@sorokin.engineer Before 20190429 my-cool-password",,,hub.docker.com,sorokin.engineer,my-cool-password, diff --git a/tests/resources/bitwarden_notes_export.csv b/tests/resources/bitwarden_notes_export.csv new file mode 100644 index 0000000..09c01cb --- /dev/null +++ b/tests/resources/bitwarden_notes_export.csv @@ -0,0 +1,10 @@ +folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp +,,login, OpenStreetMap,,,,https://www.openstreetmap.org,andrey@sorokin.engineer,my-cool-password, +,,login, VK,,,,vk.com,+1 555 555 5555,, +,,login,(deleted) airtable sheets,,,,https://airtable.com/,andrey@sorokin.engineer,my-cool-password, +card,,login,My VISA,"+1 555 555 5555 +11111, London, Baker Street 221B. +Expiration Date: 12/2099 +Name on Card: Sherlock Holmes",PIN: 1234,,,1234 5678 9012 3456,123, +,,login,Docker hub,"andrey@sorokin.engineer +Before 20190429 my-cool-password",,,hub.docker.com,sorokin.engineer,my-cool-password, diff --git a/tests/test_bitwarden_import_msecure.py b/tests/test_bitwarden_import_msecure.py index cabfa56..577e419 100644 --- a/tests/test_bitwarden_import_msecure.py +++ b/tests/test_bitwarden_import_msecure.py @@ -9,18 +9,34 @@ def test_version(): assert __version__ -def test_bitwarden_import_msecure_inplace(tmpdir, msecure_export, bitwarden_file): +def test_bitwarden_import_msecure_default_output(tmpdir, msecure_export, bitwarden_file): input_file = tmpdir.join("input.csv") input_file.write(msecure_export) runner = CliRunner() result = runner.invoke(bitwarden_import_msecure, [str(input_file)]) - assert result.exit_code == 0 + output_file = tmpdir.join("bitwarden.csv") + + # bitwarden_file.write_text(output_file.read_text(encoding="utf8")) # uncomment to refresh the expected output assert output_file.read() == bitwarden_file.read_text() +def test_bitwarden_import_msecure_note_mode_default_output(tmpdir, msecure_export, bitwarden_notes_file): + input_file = tmpdir.join("input.csv") + input_file.write(msecure_export) + + runner = CliRunner() + result = runner.invoke(bitwarden_import_msecure, [str(input_file), "--extra-fields", "notes"]) + assert result.exit_code == 0 + + output_file = tmpdir.join("bitwarden.csv") + + # bitwarden_notes_file.write_text(output_file.read_text(encoding="utf8")) # uncomment to refresh the expected output + assert output_file.read() == bitwarden_notes_file.read_text() + + def test_bitwarden_import_msecure_existing_output_file(tmpdir, msecure_export, bitwarden_file): input_file = tmpdir.join("input.txt") input_file.write(msecure_export) @@ -30,6 +46,7 @@ def test_bitwarden_import_msecure_existing_output_file(tmpdir, msecure_export, b runner = CliRunner() result = runner.invoke(bitwarden_import_msecure, [str(input_file), str(output_file)]) + assert result.exit_code == 1 assert "Output file" in result.output and "already exists" in result.output assert result.exception assert isinstance(result.exception, SystemExit) @@ -45,7 +62,7 @@ def test_bitwarden_import_msecure_to_output_file(tmpdir, msecure_export, bitward runner = CliRunner() result = runner.invoke(bitwarden_import_msecure, [str(input_file), str(output_file), "--force"]) - assert result.exit_code == 0 + assert output_file.read() == bitwarden_file.read_text() assert input_file.read() == msecure_export # Ensure input file remains unchanged