From 73632cd3684aa296d009f6f04949943781488e66 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 14 Nov 2024 21:28:55 +0100 Subject: [PATCH] [PM-12598] Create dedicated importer for Password-XP (csv) (#11751) * Create dedicated password-xp csv importer * Add support for importing unmapped columns as custom fields * Add support for importing folders and assiging items to them * On import into an organization, convert folders to collections * Register importer within importService and make it selectable via the UI Add instructions on how to export from Password XP * Mark method as private * Add docs * Add comment around folder detection * Move test data into separate file --------- Co-authored-by: Daniel James Smith Co-authored-by: Matt Bishop --- .../spec/passwordxp-csv-importer.spec.ts | 148 ++++++++++++++++++ .../test-data/passwordxp-csv/no-folder.csv.ts | 2 + .../passwordxp-with-folders.csv.ts | 13 ++ .../passwordxp-without-folders.csv.ts | 7 + .../src/components/import.component.html | 5 + libs/importer/src/importers/index.ts | 1 + .../src/importers/passwordxp-csv-importer.ts | 78 +++++++++ libs/importer/src/models/import-options.ts | 1 + libs/importer/src/services/import.service.ts | 3 + 9 files changed, 258 insertions(+) create mode 100644 libs/importer/spec/passwordxp-csv-importer.spec.ts create mode 100644 libs/importer/spec/test-data/passwordxp-csv/no-folder.csv.ts create mode 100644 libs/importer/spec/test-data/passwordxp-csv/passwordxp-with-folders.csv.ts create mode 100644 libs/importer/spec/test-data/passwordxp-csv/passwordxp-without-folders.csv.ts create mode 100644 libs/importer/src/importers/passwordxp-csv-importer.ts diff --git a/libs/importer/spec/passwordxp-csv-importer.spec.ts b/libs/importer/spec/passwordxp-csv-importer.spec.ts new file mode 100644 index 0000000000..f707b1138c --- /dev/null +++ b/libs/importer/spec/passwordxp-csv-importer.spec.ts @@ -0,0 +1,148 @@ +import { CipherType } from "@bitwarden/common/vault/enums"; + +import { PasswordXPCsvImporter } from "../src/importers"; +import { ImportResult } from "../src/models/import-result"; + +import { noFolder } from "./test-data/passwordxp-csv/no-folder.csv"; +import { withFolders } from "./test-data/passwordxp-csv/passwordxp-with-folders.csv"; +import { withoutFolders } from "./test-data/passwordxp-csv/passwordxp-without-folders.csv"; + +describe("PasswordXPCsvImporter", () => { + let importer: PasswordXPCsvImporter; + + beforeEach(() => { + importer = new PasswordXPCsvImporter(); + }); + + it("should return success false if CSV data is null", async () => { + const data = ""; + const result: ImportResult = await importer.parse(data); + expect(result.success).toBe(false); + }); + + it("should skip rows starting with >>>", async () => { + const result: ImportResult = await importer.parse(noFolder); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(0); + }); + + it("should parse CSV data and return success true", async () => { + const result: ImportResult = await importer.parse(withoutFolders); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(4); + + let cipher = result.ciphers.shift(); + expect(cipher.type).toBe(CipherType.Login); + expect(cipher.name).toBe("Title2"); + expect(cipher.notes).toBe("Test Notes"); + expect(cipher.login.username).toBe("Username2"); + expect(cipher.login.password).toBe("12345678"); + expect(cipher.login.uris[0].uri).toBe("http://URL2.com"); + + cipher = result.ciphers.shift(); + expect(cipher.type).toBe(CipherType.Login); + expect(cipher.name).toBe("Title Test 1"); + expect(cipher.notes).toBe("Test Notes 2"); + expect(cipher.login.username).toBe("Username1"); + expect(cipher.login.password).toBe("Password1"); + expect(cipher.login.uris[0].uri).toBe("http://URL1.com"); + + cipher = result.ciphers.shift(); + expect(cipher.type).toBe(CipherType.SecureNote); + expect(cipher.name).toBe("Certificate 1"); + expect(cipher.notes).toBe("Test Notes Certicate 1"); + + cipher = result.ciphers.shift(); + expect(cipher.type).toBe(CipherType.Login); + expect(cipher.name).toBe("test"); + expect(cipher.notes).toBe("Test Notes 3"); + expect(cipher.login.username).toBe("testtest"); + expect(cipher.login.password).toBe("test"); + expect(cipher.login.uris[0].uri).toBe("http://test"); + }); + + it("should parse CSV data and import unmapped columns as custom fields", async () => { + const result: ImportResult = await importer.parse(withoutFolders); + expect(result.success).toBe(true); + + const cipher = result.ciphers.shift(); + expect(cipher.type).toBe(CipherType.Login); + expect(cipher.name).toBe("Title2"); + expect(cipher.notes).toBe("Test Notes"); + expect(cipher.login.username).toBe("Username2"); + expect(cipher.login.password).toBe("12345678"); + expect(cipher.login.uris[0].uri).toBe("http://URL2.com"); + + expect(cipher.fields.length).toBe(5); + let field = cipher.fields.shift(); + expect(field.name).toBe("Account"); + expect(field.value).toBe("Account2"); + + field = cipher.fields.shift(); + expect(field.name).toBe("Modified"); + expect(field.value).toBe("27-3-2024 08:11:21"); + + field = cipher.fields.shift(); + expect(field.name).toBe("Created"); + expect(field.value).toBe("27-3-2024 08:11:21"); + + field = cipher.fields.shift(); + expect(field.name).toBe("Expire on"); + expect(field.value).toBe("27-5-2024 08:11:21"); + + field = cipher.fields.shift(); + expect(field.name).toBe("Modified by"); + expect(field.value).toBe("someone"); + }); + + it("should parse CSV data with folders and assign items to them", async () => { + const result: ImportResult = await importer.parse(withFolders); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(5); + + expect(result.folders.length).toBe(3); + let folder = result.folders.shift(); + expect(folder.name).toEqual("Test Folder"); + folder = result.folders.shift(); + expect(folder.name).toEqual("Cert folder"); + folder = result.folders.shift(); + expect(folder.name).toEqual("Cert folder/Nested folder"); + + expect(result.folderRelationships.length).toBe(4); + let folderRelationship = result.folderRelationships.shift(); + expect(folderRelationship).toEqual([1, 0]); + folderRelationship = result.folderRelationships.shift(); + expect(folderRelationship).toEqual([2, 1]); + folderRelationship = result.folderRelationships.shift(); + expect(folderRelationship).toEqual([3, 1]); + folderRelationship = result.folderRelationships.shift(); + expect(folderRelationship).toEqual([4, 2]); + folderRelationship = result.folderRelationships.shift(); + }); + + it("should convert folders to collections when importing into an organization", async () => { + importer.organizationId = "someOrg"; + const result: ImportResult = await importer.parse(withFolders); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(5); + + expect(result.collections.length).toBe(3); + expect(result.collections[0].name).toEqual("Test Folder"); + expect(result.collectionRelationships[0]).toEqual([1, 0]); + expect(result.collections[1].name).toEqual("Cert folder"); + expect(result.collectionRelationships[1]).toEqual([2, 1]); + expect(result.collectionRelationships[2]).toEqual([3, 1]); + expect(result.collections[2].name).toEqual("Cert folder/Nested folder"); + + expect(result.collectionRelationships.length).toBe(4); + let collectionRelationship = result.collectionRelationships.shift(); + expect(collectionRelationship).toEqual([1, 0]); + collectionRelationship = result.collectionRelationships.shift(); + expect(collectionRelationship).toEqual([2, 1]); + collectionRelationship = result.collectionRelationships.shift(); + expect(collectionRelationship).toEqual([3, 1]); + collectionRelationship = result.collectionRelationships.shift(); + expect(collectionRelationship).toEqual([4, 2]); + collectionRelationship = result.collectionRelationships.shift(); + }); +}); diff --git a/libs/importer/spec/test-data/passwordxp-csv/no-folder.csv.ts b/libs/importer/spec/test-data/passwordxp-csv/no-folder.csv.ts new file mode 100644 index 0000000000..056db68d5a --- /dev/null +++ b/libs/importer/spec/test-data/passwordxp-csv/no-folder.csv.ts @@ -0,0 +1,2 @@ +export const noFolder = `Title;User name;Account;URL;Password;Modified;Created;Expire on;Description;Modified by +>>>`; diff --git a/libs/importer/spec/test-data/passwordxp-csv/passwordxp-with-folders.csv.ts b/libs/importer/spec/test-data/passwordxp-csv/passwordxp-with-folders.csv.ts new file mode 100644 index 0000000000..c7cfe82575 --- /dev/null +++ b/libs/importer/spec/test-data/passwordxp-csv/passwordxp-with-folders.csv.ts @@ -0,0 +1,13 @@ +export const withFolders = `Title;User name;Account;URL;Password;Modified;Created;Expire on;Description;Modified by +>>> +Title2;Username2;Account2;http://URL2.com;12345678;27-3-2024 08:11:21;27-3-2024 08:11:21;;; + +[Test Folder] +Title Test 1;Username1;Account1;http://URL1.com;Password1;27-3-2024 08:10:52;27-3-2024 08:10:52;;; + +[Cert folder] +Certificate 1;;;;;27-3-2024 10:22:39;27-3-2024 10:22:39;;; +test;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;; + +[Cert folder\\Nested folder]; +test2;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;;`; diff --git a/libs/importer/spec/test-data/passwordxp-csv/passwordxp-without-folders.csv.ts b/libs/importer/spec/test-data/passwordxp-csv/passwordxp-without-folders.csv.ts new file mode 100644 index 0000000000..eb9055611b --- /dev/null +++ b/libs/importer/spec/test-data/passwordxp-csv/passwordxp-without-folders.csv.ts @@ -0,0 +1,7 @@ +export const withoutFolders = `Title;User name;Account;URL;Password;Modified;Created;Expire on;Description;Modified by +>>> +Title2;Username2;Account2;http://URL2.com;12345678;27-3-2024 08:11:21;27-3-2024 08:11:21;27-5-2024 08:11:21;Test Notes;someone +Title Test 1;Username1;Account1;http://URL1.com;Password1;27-3-2024 08:10:52;27-3-2024 08:10:52;;Test Notes 2; +Certificate 1;;;;;27-3-2024 10:22:39;27-3-2024 10:22:39;;Test Notes Certicate 1; +test;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;Test Notes 3; +`; diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html index 5b67fc47a7..33056265de 100644 --- a/libs/importer/src/components/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -380,6 +380,11 @@ In the ProtonPass browser extension, go to Settings > Export. Export without PGP encryption and save the zip file. + + Select Database → Export to file... menu. → Within the export options, ensure + Folder names and Column titles is checked. → Press Browse... button and choose the + target file and set it's type to CSV file. + Open the FullClient, go to the Main Menu and select Export. Start the export passwords wizard and follow the instructions to export a CSV file. diff --git a/libs/importer/src/importers/index.ts b/libs/importer/src/importers/index.ts index 45658f1e6c..19b22cfa80 100644 --- a/libs/importer/src/importers/index.ts +++ b/libs/importer/src/importers/index.ts @@ -45,6 +45,7 @@ export { PasswordBossJsonImporter } from "./passwordboss-json-importer"; export { PasswordDragonXmlImporter } from "./passworddragon-xml-importer"; export { PasswordSafeXmlImporter } from "./passwordsafe-xml-importer"; export { PasswordWalletTxtImporter } from "./passwordwallet-txt-importer"; +export { PasswordXPCsvImporter } from "./passwordxp-csv-importer"; export { ProtonPassJsonImporter } from "./protonpass/protonpass-json-importer"; export { PsonoJsonImporter } from "./psono/psono-json-importer"; export { RememBearCsvImporter } from "./remembear-csv-importer"; diff --git a/libs/importer/src/importers/passwordxp-csv-importer.ts b/libs/importer/src/importers/passwordxp-csv-importer.ts new file mode 100644 index 0000000000..461432e98d --- /dev/null +++ b/libs/importer/src/importers/passwordxp-csv-importer.ts @@ -0,0 +1,78 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { ImportResult } from "../models/import-result"; + +import { BaseImporter } from "./base-importer"; +import { Importer } from "./importer"; + +const _mappedColumns = new Set(["Title", "Username", "URL", "Password", "Description"]); + +/** + * PasswordXP CSV importer + */ +export class PasswordXPCsvImporter extends BaseImporter implements Importer { + /** + * Parses the PasswordXP CSV data. + * @param data + */ + parse(data: string): Promise { + // The header column 'User name' is parsed by the parser, but cannot be used as a variable. This converts it to a valid variable name, prior to parsing. + data = data.replace(";User name;", ";Username;"); + + const result = new ImportResult(); + const results = this.parseCsv(data, true, { skipEmptyLines: true }); + if (results == null) { + result.success = false; + return Promise.resolve(result); + } + let currentFolderName = ""; + results.forEach((row) => { + // Skip rows starting with '>>>' as they indicate items following have no folder assigned to them + if (row.Title == ">>>") { + return; + } + + const title = row.Title; + // If the title is in the format [title], then it is a folder name + if (title.startsWith("[") && title.endsWith("]")) { + currentFolderName = title.startsWith("/") + ? title.replace("/", "") + : title.substring(1, title.length - 1); + return; + } + + if (!Utils.isNullOrWhitespace(currentFolderName)) { + this.processFolder(result, currentFolderName); + } + + const cipher = this.initLoginCipher(); + cipher.name = this.getValueOrDefault(row.Title); + cipher.login.username = this.getValueOrDefault(row.Username); + cipher.notes = this.getValueOrDefault(row.Description); + cipher.login.uris = this.makeUriArray(row.URL); + cipher.login.password = this.getValueOrDefault(row.Password); + + this.importUnmappedFields(cipher, row, _mappedColumns); + + this.convertToNoteIfNeeded(cipher); + this.cleanupCipher(cipher); + result.ciphers.push(cipher); + }); + + if (this.organization) { + this.moveFoldersToCollections(result); + } + + result.success = true; + return Promise.resolve(result); + } + + private importUnmappedFields(cipher: CipherView, row: any, mappedValues: Set) { + const unmappedFields = Object.keys(row).filter((x) => !mappedValues.has(x)); + unmappedFields.forEach((key) => { + const item = row as any; + this.processKvp(cipher, key, item[key]); + }); + } +} diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index f656c728ff..bd3202067c 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -70,6 +70,7 @@ export const regularImportOptions = [ { id: "nordpasscsv", name: "Nordpass (csv)" }, { id: "psonojson", name: "Psono (json)" }, { id: "passkyjson", name: "Passky (json)" }, + { id: "passwordxpcsv", name: "Password XP (csv)" }, { id: "netwrixpasswordsecure", name: "Netwrix Password Secure (csv)" }, ] as const; diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 6bfc5d5ce9..fcbbc2c06e 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -84,6 +84,7 @@ import { UpmCsvImporter, YotiCsvImporter, ZohoVaultCsvImporter, + PasswordXPCsvImporter, } from "../importers"; import { Importer } from "../importers/importer"; import { @@ -336,6 +337,8 @@ export class ImportService implements ImportServiceAbstraction { return new PasskyJsonImporter(); case "protonpass": return new ProtonPassJsonImporter(this.i18nService); + case "passwordxpcsv": + return new PasswordXPCsvImporter(); case "netwrixpasswordsecure": return new NetwrixPasswordSecureCsvImporter(); default: