1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[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 <djsmith85@users.noreply.github.com>
Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
Daniel James Smith
2024-11-14 21:28:55 +01:00
committed by GitHub
parent d4a381e1bd
commit 73632cd368
9 changed files with 258 additions and 0 deletions

View File

@@ -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();
});
});

View File

@@ -0,0 +1,2 @@
export const noFolder = `Title;User name;Account;URL;Password;Modified;Created;Expire on;Description;Modified by
>>>`;

View File

@@ -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;;;`;

View File

@@ -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;
`;

View File

@@ -380,6 +380,11 @@
In the ProtonPass browser extension, go to Settings > Export. Export without PGP In the ProtonPass browser extension, go to Settings > Export. Export without PGP
encryption and save the zip file. encryption and save the zip file.
</ng-container> </ng-container>
<ng-container *ngIf="format === 'passwordxpcsv'">
Select Database &rarr; Export to file... menu. &rarr; Within the export options, ensure
Folder names and Column titles is checked. &rarr; Press Browse... button and choose the
target file and set it's type to CSV file.
</ng-container>
<ng-container *ngIf="format === 'netwrixpasswordsecure'"> <ng-container *ngIf="format === 'netwrixpasswordsecure'">
Open the FullClient, go to the Main Menu and select Export. Start the export passwords 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. wizard and follow the instructions to export a CSV file.

View File

@@ -45,6 +45,7 @@ export { PasswordBossJsonImporter } from "./passwordboss-json-importer";
export { PasswordDragonXmlImporter } from "./passworddragon-xml-importer"; export { PasswordDragonXmlImporter } from "./passworddragon-xml-importer";
export { PasswordSafeXmlImporter } from "./passwordsafe-xml-importer"; export { PasswordSafeXmlImporter } from "./passwordsafe-xml-importer";
export { PasswordWalletTxtImporter } from "./passwordwallet-txt-importer"; export { PasswordWalletTxtImporter } from "./passwordwallet-txt-importer";
export { PasswordXPCsvImporter } from "./passwordxp-csv-importer";
export { ProtonPassJsonImporter } from "./protonpass/protonpass-json-importer"; export { ProtonPassJsonImporter } from "./protonpass/protonpass-json-importer";
export { PsonoJsonImporter } from "./psono/psono-json-importer"; export { PsonoJsonImporter } from "./psono/psono-json-importer";
export { RememBearCsvImporter } from "./remembear-csv-importer"; export { RememBearCsvImporter } from "./remembear-csv-importer";

View File

@@ -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<ImportResult> {
// 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<string>) {
const unmappedFields = Object.keys(row).filter((x) => !mappedValues.has(x));
unmappedFields.forEach((key) => {
const item = row as any;
this.processKvp(cipher, key, item[key]);
});
}
}

View File

@@ -70,6 +70,7 @@ export const regularImportOptions = [
{ id: "nordpasscsv", name: "Nordpass (csv)" }, { id: "nordpasscsv", name: "Nordpass (csv)" },
{ id: "psonojson", name: "Psono (json)" }, { id: "psonojson", name: "Psono (json)" },
{ id: "passkyjson", name: "Passky (json)" }, { id: "passkyjson", name: "Passky (json)" },
{ id: "passwordxpcsv", name: "Password XP (csv)" },
{ id: "netwrixpasswordsecure", name: "Netwrix Password Secure (csv)" }, { id: "netwrixpasswordsecure", name: "Netwrix Password Secure (csv)" },
] as const; ] as const;

View File

@@ -84,6 +84,7 @@ import {
UpmCsvImporter, UpmCsvImporter,
YotiCsvImporter, YotiCsvImporter,
ZohoVaultCsvImporter, ZohoVaultCsvImporter,
PasswordXPCsvImporter,
} from "../importers"; } from "../importers";
import { Importer } from "../importers/importer"; import { Importer } from "../importers/importer";
import { import {
@@ -336,6 +337,8 @@ export class ImportService implements ImportServiceAbstraction {
return new PasskyJsonImporter(); return new PasskyJsonImporter();
case "protonpass": case "protonpass":
return new ProtonPassJsonImporter(this.i18nService); return new ProtonPassJsonImporter(this.i18nService);
case "passwordxpcsv":
return new PasswordXPCsvImporter();
case "netwrixpasswordsecure": case "netwrixpasswordsecure":
return new NetwrixPasswordSecureCsvImporter(); return new NetwrixPasswordSecureCsvImporter();
default: default: