From 1eb40a48911bccf726968430bab66b517eede34c Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Thu, 29 Apr 2021 12:51:35 +0200 Subject: [PATCH] Add support to import from Nordpass(.csv) (#360) * Add support for parsing .csv files from Nordpass * Remove whitespace before extracting CardExpiration * Add curlybraces to one-liner if's as requested * NordPassImporter: Process more complex names --- .../importers/nordpassCsvImporter.spec.ts | 179 ++++++++++++++++++ .../testData/nordpassCsv/nordpass.card.csv.ts | 2 + .../nordpassCsv/nordpass.identity.csv.ts | 2 + .../nordpassCsv/nordpass.login.csv.ts | 2 + .../nordpassCsv/nordpass.secureNote.csv.ts | 3 + src/importers/baseImporter.ts | 1 + src/importers/nordpassCsvImporter.ts | 149 +++++++++++++++ src/services/import.service.ts | 4 + 8 files changed, 342 insertions(+) create mode 100644 spec/common/importers/nordpassCsvImporter.spec.ts create mode 100644 spec/common/importers/testData/nordpassCsv/nordpass.card.csv.ts create mode 100644 spec/common/importers/testData/nordpassCsv/nordpass.identity.csv.ts create mode 100644 spec/common/importers/testData/nordpassCsv/nordpass.login.csv.ts create mode 100644 spec/common/importers/testData/nordpassCsv/nordpass.secureNote.csv.ts create mode 100644 src/importers/nordpassCsvImporter.ts diff --git a/spec/common/importers/nordpassCsvImporter.spec.ts b/spec/common/importers/nordpassCsvImporter.spec.ts new file mode 100644 index 0000000000..91e64cff65 --- /dev/null +++ b/spec/common/importers/nordpassCsvImporter.spec.ts @@ -0,0 +1,179 @@ +import { NordPassCsvImporter as Importer } from '../../../src/importers/nordpassCsvImporter'; + +import { CipherType, SecureNoteType } from '../../../src/enums'; +import { CipherView, IdentityView } from '../../../src/models/view/'; + +import { data as creditCardData } from './testData/nordpassCsv/nordpass.card.csv'; +import { data as identityData } from './testData/nordpassCsv/nordpass.identity.csv'; +import { data as loginData } from './testData/nordpassCsv/nordpass.login.csv'; +import { data as secureNoteData } from './testData/nordpassCsv/nordpass.secureNote.csv'; + +const namesTestData = [ + { + title: 'Given #fullName should set firstName', + fullName: 'MyFirstName', + expected: Object.assign(new IdentityView(), { + firstName: 'MyFirstName', + middleName: null, + lastName: null, + }), + }, + { + title: 'Given #fullName should set first- and lastName', + fullName: 'MyFirstName MyLastName', + expected: Object.assign(new IdentityView(), { + firstName: 'MyFirstName', + middleName: null, + lastName: 'MyLastName', + }), + }, + { + title: 'Given #fullName should set first-, middle and lastName', + fullName: 'MyFirstName MyMiddleName MyLastName', + expected: Object.assign(new IdentityView(), { + firstName: 'MyFirstName', + middleName: 'MyMiddleName', + lastName: 'MyLastName', + }), + }, + { + title: 'Given #fullName should set first-, middle and lastName with Jr', + fullName: 'MyFirstName MyMiddleName MyLastName Jr', + expected: Object.assign(new IdentityView(), { + firstName: 'MyFirstName', + middleName: 'MyMiddleName', + lastName: 'MyLastName Jr', + }), + }, + { + title: 'Given #fullName should set first-, middle and lastName with Jr and III', + fullName: 'MyFirstName MyMiddleName MyLastName Jr III', + expected: Object.assign(new IdentityView(), { + firstName: 'MyFirstName', + middleName: 'MyMiddleName', + lastName: 'MyLastName Jr III', + }), + }, +]; + + +function expectLogin(cipher: CipherView) { + expect(cipher.type).toBe(CipherType.Login); + + expect(cipher.name).toBe('SomeVaultItemName'); + expect(cipher.notes).toBe('Some note for the VaultItem'); + expect(cipher.login.uri).toBe('https://example.com'); + expect(cipher.login.username).toBe('hello@bitwarden.com'); + expect(cipher.login.password).toBe('someStrongPassword'); +} + +function expectCreditCard(cipher: CipherView) { + expect(cipher.type).toBe(CipherType.Card); + + expect(cipher.name).toBe('SomeVisa'); + expect(cipher.card.brand).toBe('Visa'); + expect(cipher.card.cardholderName).toBe('SomeHolder'); + expect(cipher.card.number).toBe('4024007103939509'); + expect(cipher.card.code).toBe('123'); + expect(cipher.card.expMonth).toBe('1'); + expect(cipher.card.expYear).toBe('22'); +} + +function expectIdentity(cipher: CipherView) { + expect(cipher.type).toBe(CipherType.Identity); + + expect(cipher.name).toBe('SomeTitle'); + expect(cipher.identity.fullName).toBe('MyFirstName MyMiddleName MyLastName'); + expect(cipher.identity.firstName).toBe('MyFirstName'); + expect(cipher.identity.middleName).toBe('MyMiddleName'); + expect(cipher.identity.lastName).toBe('MyLastName'); + expect(cipher.identity.email).toBe('hello@bitwarden.com'); + expect(cipher.identity.phone).toBe('123456789'); + + expect(cipher.identity.address1).toBe('Test street 123'); + expect(cipher.identity.address2).toBe('additional addressinfo'); + expect(cipher.identity.postalCode).toBe('123456'); + expect(cipher.identity.city).toBe('Cologne'); + expect(cipher.identity.state).toBe('North-Rhine-Westphalia'); + expect(cipher.identity.country).toBe('GERMANY'); + expect(cipher.notes).toBe('SomeNoteToMyIdentity'); +} + +function expectSecureNote(cipher: CipherView) { + expect(cipher.type).toBe(CipherType.SecureNote); + + expect(cipher.name).toBe('MySuperSecureNoteTitle'); + expect(cipher.secureNote.type).toBe(SecureNoteType.Generic); + expect(cipher.notes).toBe('MySuperSecureNote'); +} + +describe('NordPass CSV Importer', () => { + let importer: Importer; + beforeEach(() => { + importer = new Importer(); + }); + + it('should parse login records', async () => { + const result = await importer.parse(loginData); + + expect(result).not.toBeNull(); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + const cipher = result.ciphers[0]; + expectLogin(cipher); + }); + + it('should parse credit card records', async () => { + const result = await importer.parse(creditCardData); + + expect(result).not.toBeNull(); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + const cipher = result.ciphers[0]; + expectCreditCard(cipher); + }); + + it('should parse identity records', async () => { + const result = await importer.parse(identityData.replace('#fullName', 'MyFirstName MyMiddleName MyLastName')); + + expect(result).not.toBeNull(); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + const cipher = result.ciphers[0]; + expectIdentity(cipher); + }); + + namesTestData.forEach(data => { + it(data.title.replace('#fullName', data.fullName), async () => { + const result = await importer.parse(identityData.replace('#fullName', data.fullName)); + + expect(result).not.toBeNull(); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + const cipher = result.ciphers[0]; + expect(cipher.identity.firstName).toBe(data.expected.firstName); + expect(cipher.identity.middleName).toBe(data.expected.middleName); + expect(cipher.identity.lastName).toBe(data.expected.lastName); + }); + }); + + it('should parse secureNote records', async () => { + const result = await importer.parse(secureNoteData); + + expect(result).not.toBeNull(); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + const cipher = result.ciphers[0]; + expectSecureNote(cipher); + }); + + it('should parse an item and create a folder', async () => { + const result = await importer.parse(secureNoteData); + + expect(result).not.toBeNull(); + expect(result.success).toBe(true); + expect(result.folders.length).toBe(1); + const folder = result.folders[0]; + expect(folder.name).toBe('notesFolder'); + }); +}); diff --git a/spec/common/importers/testData/nordpassCsv/nordpass.card.csv.ts b/spec/common/importers/testData/nordpassCsv/nordpass.card.csv.ts new file mode 100644 index 0000000000..8d79d2b1bf --- /dev/null +++ b/spec/common/importers/testData/nordpassCsv/nordpass.card.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note,cardholdername,cardnumber,cvc,expirydate,zipcode,folder,full_name,phone_number,email,address1,address2,city,country,state +SomeVisa,,,,,SomeHolder,4024007103939509,123,01 / 22,12345,,,,,,,,,`; diff --git a/spec/common/importers/testData/nordpassCsv/nordpass.identity.csv.ts b/spec/common/importers/testData/nordpassCsv/nordpass.identity.csv.ts new file mode 100644 index 0000000000..4dde389c72 --- /dev/null +++ b/spec/common/importers/testData/nordpassCsv/nordpass.identity.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note,cardholdername,cardnumber,cvc,expirydate,zipcode,folder,full_name,phone_number,email,address1,address2,city,country,state +SomeTitle,,,,SomeNoteToMyIdentity,,,,,123456,,#fullName,123456789,hello@bitwarden.com,Test street 123,additional addressinfo,Cologne,Germany,North-Rhine-Westphalia`; diff --git a/spec/common/importers/testData/nordpassCsv/nordpass.login.csv.ts b/spec/common/importers/testData/nordpassCsv/nordpass.login.csv.ts new file mode 100644 index 0000000000..e2af76b0fb --- /dev/null +++ b/spec/common/importers/testData/nordpassCsv/nordpass.login.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note,cardholdername,cardnumber,cvc,expirydate,zipcode,folder,full_name,phone_number,email,address1,address2,city,country,state +SomeVaultItemName,https://example.com,hello@bitwarden.com,someStrongPassword,Some note for the VaultItem,,,,,,SomeFolderForVaultItem,,,,,,,,`; diff --git a/spec/common/importers/testData/nordpassCsv/nordpass.secureNote.csv.ts b/spec/common/importers/testData/nordpassCsv/nordpass.secureNote.csv.ts new file mode 100644 index 0000000000..7d8c23078f --- /dev/null +++ b/spec/common/importers/testData/nordpassCsv/nordpass.secureNote.csv.ts @@ -0,0 +1,3 @@ +export const data = `name,url,username,password,note,cardholdername,cardnumber,cvc,expirydate,zipcode,folder,full_name,phone_number,email,address1,address2,city,country,state +notesFolder,,,,,,,,,,,,,,,,,, +MySuperSecureNoteTitle,,,,MySuperSecureNote,,,,,,notesFolder,,,,,,,,`; \ No newline at end of file diff --git a/src/importers/baseImporter.ts b/src/importers/baseImporter.ts index 366f97a651..ac1bf8486a 100644 --- a/src/importers/baseImporter.ts +++ b/src/importers/baseImporter.ts @@ -241,6 +241,7 @@ export abstract class BaseImporter { protected setCardExpiration(cipher: CipherView, expiration: string): boolean { if (!this.isNullOrWhitespace(expiration)) { + expiration = expiration.replace(/\s/g, ''); const parts = expiration.split('/'); if (parts.length === 2) { let month: string = null; diff --git a/src/importers/nordpassCsvImporter.ts b/src/importers/nordpassCsvImporter.ts new file mode 100644 index 0000000000..39ef44755d --- /dev/null +++ b/src/importers/nordpassCsvImporter.ts @@ -0,0 +1,149 @@ +import { BaseImporter } from './baseImporter'; +import { Importer } from './importer'; + +import { ImportResult } from '../models/domain/importResult'; + +import { CipherView } from '../models/view/cipherView'; +import { LoginView } from '../models/view/loginView'; + +import { CipherType } from '../enums/cipherType'; +import { SecureNoteType } from '../enums/secureNoteType'; + +type nodePassCsvParsed = { + name: string; + url: string; + username: string; + password: string; + note: string; + cardholdername: string; + cardnumber: string; + cvc: string; + expirydate: string; + zipcode: string; + folder: string; + full_name: string; + phone_number: string; + email: string; + address1: string; + address2: string; + city: string; + country: string; + state: string; +}; + +export class NordPassCsvImporter extends BaseImporter implements Importer { + parse(data: string): Promise { + const result = new ImportResult(); + const results: nodePassCsvParsed[] = this.parseCsv(data, true); + if (results == null) { + result.success = false; + return Promise.resolve(result); + } + + results.forEach(record => { + + const recordType = this.evaluateType(record); + if (recordType === undefined) { + return; + } + + if (!this.organization) { + this.processFolder(result, record.folder); + } + + const cipher = new CipherView(); + cipher.name = this.getValueOrDefault(record.name, '--'); + cipher.notes = this.getValueOrDefault(record.note); + + switch (recordType) { + case CipherType.Login: + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.username = this.getValueOrDefault(record.username); + cipher.login.password = this.getValueOrDefault(record.password); + cipher.login.uris = this.makeUriArray(record.url); + break; + case CipherType.Card: + cipher.type = CipherType.Card; + cipher.card.cardholderName = this.getValueOrDefault(record.cardholdername); + cipher.card.number = this.getValueOrDefault(record.cardnumber); + cipher.card.code = this.getValueOrDefault(record.cvc); + cipher.card.brand = this.getCardBrand(cipher.card.number); + this.setCardExpiration(cipher, record.expirydate); + break; + + case CipherType.Identity: + cipher.type = CipherType.Identity; + + this.processName(cipher, this.getValueOrDefault(record.full_name)); + cipher.identity.address1 = this.getValueOrDefault(record.address1); + cipher.identity.address2 = this.getValueOrDefault(record.address2); + cipher.identity.city = this.getValueOrDefault(record.city); + cipher.identity.state = this.getValueOrDefault(record.state); + cipher.identity.postalCode = this.getValueOrDefault(record.zipcode); + cipher.identity.country = this.getValueOrDefault(record.country); + if (cipher.identity.country != null) { + cipher.identity.country = cipher.identity.country.toUpperCase(); + } + cipher.identity.email = this.getValueOrDefault(record.email); + cipher.identity.phone = this.getValueOrDefault(record.phone_number); + break; + case CipherType.SecureNote: + cipher.type = CipherType.SecureNote; + cipher.secureNote.type = SecureNoteType.Generic; + break; + default: + break; + } + + this.cleanupCipher(cipher); + result.ciphers.push(cipher); + }); + + if (this.organization) { + this.moveFoldersToCollections(result); + } + + result.success = true; + return Promise.resolve(result); + } + + private evaluateType(record: nodePassCsvParsed): CipherType { + + if (!this.isNullOrWhitespace(record.username)) { + return CipherType.Login; + } + + if (!this.isNullOrWhitespace(record.cardnumber)) { + return CipherType.Card; + } + + if (!this.isNullOrWhitespace(record.full_name)) { + return CipherType.Identity; + } + + if (!this.isNullOrWhitespace(record.note)) { + return CipherType.SecureNote; + } + + return undefined; + } + + private processName(cipher: CipherView, fullName: string) { + + if (this.isNullOrWhitespace(fullName)) { + return; + } + + const nameParts = fullName.split(' '); + if (nameParts.length > 0) { + cipher.identity.firstName = this.getValueOrDefault(nameParts[0]); + } + if (nameParts.length === 2) { + cipher.identity.lastName = this.getValueOrDefault(nameParts[1]); + } else if (nameParts.length >= 3) { + cipher.identity.middleName = this.getValueOrDefault(nameParts[1]); + cipher.identity.lastName = nameParts.slice(2, nameParts.length).join(' '); + } + } +} diff --git a/src/services/import.service.ts b/src/services/import.service.ts index 2007092da3..4f4364eb19 100644 --- a/src/services/import.service.ts +++ b/src/services/import.service.ts @@ -54,6 +54,7 @@ import { LogMeOnceCsvImporter } from '../importers/logMeOnceCsvImporter'; import { MeldiumCsvImporter } from '../importers/meldiumCsvImporter'; import { MSecureCsvImporter } from '../importers/msecureCsvImporter'; import { MykiCsvImporter } from '../importers/mykiCsvImporter'; +import { NordPassCsvImporter } from '../importers/nordpassCsvImporter'; import { OnePassword1PifImporter } from '../importers/onepasswordImporters/onepassword1PifImporter'; import { OnePasswordMacCsvImporter } from '../importers/onepasswordImporters/onepasswordMacCsvImporter'; import { OnePasswordWinCsvImporter } from '../importers/onepasswordImporters/onepasswordWinCsvImporter'; @@ -137,6 +138,7 @@ export class ImportService implements ImportServiceAbstraction { { id: 'codebookcsv', name: 'Codebook (csv)' }, { id: 'encryptrcsv', name: 'Encryptr (csv)' }, { id: 'yoticsv', name: 'Yoti (csv)' }, + { id: 'nordpasscsv', name: 'Nordpass (csv)' }, ]; constructor(private cipherService: CipherService, private folderService: FolderService, @@ -294,6 +296,8 @@ export class ImportService implements ImportServiceAbstraction { return new EncryptrCsvImporter(); case 'yoticsv': return new YotiCsvImporter(); + case 'nordpasscsv': + return new NordPassCsvImporter(); default: return null; }