Skip to content

Commit

Permalink
--extra-fields
Browse files Browse the repository at this point in the history
  • Loading branch information
andgineer committed Mar 28, 2024
1 parent 17032f4 commit 73b15ec
Show file tree
Hide file tree
Showing 7 changed files with 86 additions and 21 deletions.
7 changes: 6 additions & 1 deletion docs/src/en/index.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
7 changes: 6 additions & 1 deletion docs/src/ru/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# bitwarden-import-msecure

Переход с mSecure на Bitwarden
Переход с mSecure на Bitwarden.

В отличие от встроенного инструмента импорта Bitwarden, этот скрипт не помещает каждый секрет в отдельную папку.
Вместо этого он организует секреты в папки по смыслу и предлагает несколько опций для настройки процесса импорта.

Кроме того, этот простой скрипт на Python может быть легко изменен для удовлетворения ваших конкретных потребностей.

## Установка

Expand Down
49 changes: 36 additions & 13 deletions src/bitwarden_import_msecure/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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:
Expand All @@ -80,35 +92,35 @@ 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:
if field_values[parts[0]]:
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
"", # favorite
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
Expand All @@ -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
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 3 additions & 3 deletions tests/resources/bitwarden_export.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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,
10 changes: 10 additions & 0 deletions tests/resources/bitwarden_notes_export.csv
Original file line number Diff line number Diff line change
@@ -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,
23 changes: 20 additions & 3 deletions tests/test_bitwarden_import_msecure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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

0 comments on commit 73b15ec

Please sign in to comment.