1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 08:43:33 +00:00

[PM-21040] Update Ciphers after editing in Reports (#14590)

This commit is contained in:
Vijay Oommen
2025-06-04 09:04:33 -05:00
committed by GitHub
parent d249d682fe
commit 032fedf308
7 changed files with 343 additions and 74 deletions

View File

@@ -0,0 +1,122 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault";
import { VaultItemDialogResult } from "../../../vault/components/vault-item-dialog/vault-item-dialog.component";
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
import { CipherReportComponent } from "./cipher-report.component";
describe("CipherReportComponent", () => {
let component: CipherReportComponent;
let mockAccountService: MockProxy<AccountService>;
let mockAdminConsoleCipherFormConfigService: MockProxy<AdminConsoleCipherFormConfigService>;
const mockCipher = {
id: "122-333-444",
type: CipherType.Login,
orgId: "222-444-555",
login: {
username: "test-username",
password: "test-password",
totp: "123",
},
decrypt: jest.fn().mockResolvedValue({ id: "cipher1", name: "Updated" }),
} as unknown as Cipher;
const mockCipherService = mock<CipherService>();
mockCipherService.get.mockResolvedValue(mockCipher as unknown as Cipher);
mockCipherService.getKeyForCipherKeyDecryption.mockResolvedValue({});
mockCipherService.deleteWithServer.mockResolvedValue(undefined);
mockCipherService.softDeleteWithServer.mockResolvedValue(undefined);
beforeEach(() => {
mockAccountService = mock<AccountService>();
mockAccountService.activeAccount$ = of({ id: "user1" } as any);
mockAdminConsoleCipherFormConfigService = mock<AdminConsoleCipherFormConfigService>();
component = new CipherReportComponent(
mockCipherService,
mock<DialogService>(),
mock<PasswordRepromptService>(),
mock<OrganizationService>(),
mockAccountService,
mock<I18nService>(),
mock<SyncService>(),
mock<CipherFormConfigService>(),
mockAdminConsoleCipherFormConfigService,
);
component.ciphers = [];
component.allCiphers = [];
});
it("should remove the cipher from the report if it was deleted", async () => {
const cipherToDelete = { id: "cipher1" } as any;
component.ciphers = [cipherToDelete, { id: "cipher2" } as any];
jest.spyOn(component, "determinedUpdatedCipherReportStatus").mockResolvedValue(null);
await component.refresh(VaultItemDialogResult.Deleted, cipherToDelete);
expect(component.ciphers).toEqual([{ id: "cipher2" }]);
expect(component.determinedUpdatedCipherReportStatus).toHaveBeenCalledWith(
VaultItemDialogResult.Deleted,
cipherToDelete,
);
});
it("should update the cipher in the report if it was saved", async () => {
const cipherViewToUpdate = { ...mockCipher } as unknown as CipherView;
const updatedCipher = { ...mockCipher, name: "Updated" } as unknown as Cipher;
const updatedCipherView = { ...updatedCipher } as unknown as CipherView;
component.ciphers = [cipherViewToUpdate];
mockCipherService.get.mockResolvedValue(updatedCipher);
mockCipherService.getKeyForCipherKeyDecryption.mockResolvedValue("key");
jest.spyOn(updatedCipher, "decrypt").mockResolvedValue(updatedCipherView);
jest
.spyOn(component, "determinedUpdatedCipherReportStatus")
.mockResolvedValue(updatedCipherView);
await component.refresh(VaultItemDialogResult.Saved, updatedCipherView);
expect(component.ciphers).toEqual([updatedCipherView]);
expect(component.determinedUpdatedCipherReportStatus).toHaveBeenCalledWith(
VaultItemDialogResult.Saved,
updatedCipherView,
);
});
it("should remove the cipher from the report if it no longer meets the criteria after saving", async () => {
const cipherViewToUpdate = { ...mockCipher } as unknown as CipherView;
const updatedCipher = { ...mockCipher, name: "Updated" } as unknown as Cipher;
const updatedCipherView = { ...updatedCipher } as unknown as CipherView;
component.ciphers = [cipherViewToUpdate];
mockCipherService.get.mockResolvedValue(updatedCipher);
mockCipherService.getKeyForCipherKeyDecryption.mockResolvedValue("key");
jest.spyOn(updatedCipher, "decrypt").mockResolvedValue(updatedCipherView);
jest.spyOn(component, "determinedUpdatedCipherReportStatus").mockResolvedValue(null);
await component.refresh(VaultItemDialogResult.Saved, updatedCipherView);
expect(component.ciphers).toEqual([]);
expect(component.determinedUpdatedCipherReportStatus).toHaveBeenCalledWith(
VaultItemDialogResult.Saved,
updatedCipherView,
);
});
});

View File

@@ -213,8 +213,68 @@ export class CipherReportComponent implements OnDestroy {
this.allCiphers = []; this.allCiphers = [];
} }
protected async refresh(result: VaultItemDialogResult, cipher: CipherView) { async refresh(result: VaultItemDialogResult, cipher: CipherView) {
await this.load(); if (result === VaultItemDialogResult.Deleted) {
// update downstream report status if the cipher was deleted
await this.determinedUpdatedCipherReportStatus(result, cipher);
// the cipher was deleted, filter it out from the report.
this.ciphers = this.ciphers.filter((ciph) => ciph.id !== cipher.id);
this.filterCiphersByOrg(this.ciphers);
return;
}
if (result == VaultItemDialogResult.Saved) {
// Ensure we have the latest cipher data after saving.
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
let updatedCipher = await this.cipherService.get(cipher.id, activeUserId);
if (this.isAdminConsoleActive) {
updatedCipher = await this.adminConsoleCipherFormConfigService.getCipher(
cipher.id as CipherId,
this.organization,
);
}
// convert cipher to cipher view model
const updatedCipherView = await updatedCipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId),
);
// request downstream report status if the cipher was updated
// this will return a null if the updated cipher does not meet the criteria for the report
const updatedReportResult = await this.determinedUpdatedCipherReportStatus(
result,
updatedCipherView,
);
// determine the index of the updated cipher in the report
const index = this.ciphers.findIndex((c) => c.id === updatedCipherView.id);
// the updated cipher does not meet the criteria for the report, it returns a null
if (updatedReportResult === null) {
this.ciphers.splice(index, 1);
}
// the cipher is already in the report, update it.
if (updatedReportResult !== null && index > -1) {
this.ciphers[index] = updatedReportResult;
}
// apply filters and set the data source
this.filterCiphersByOrg(this.ciphers);
}
}
async determinedUpdatedCipherReportStatus(
result: VaultItemDialogResult,
updatedCipherView: CipherView,
): Promise<CipherView | null> {
// Implement the logic to determine if the updated cipher is still in the report.
// This could be checking if the password is still weak or exposed, etc.
// For now, we will return the updated cipher view as is.
// Replace this with your actual logic in the child classes.
return updatedCipherView;
} }
protected async repromptCipher(c: CipherView) { protected async repromptCipher(c: CipherView) {

View File

@@ -10,6 +10,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault";
import { VaultItemDialogResult } from "@bitwarden/web-vault/app/vault/components/vault-item-dialog/vault-item-dialog.component";
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
@@ -73,10 +74,9 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
return; return;
} }
const promise = this.auditService.passwordLeaked(login.password).then((exposedCount) => { const promise = this.isPasswordExposed(ciph).then((result) => {
if (exposedCount > 0) { if (result) {
const row = { ...ciph, exposedXTimes: exposedCount } as ReportResult; exposedPasswordCiphers.push(result);
exposedPasswordCiphers.push(row);
} }
}); });
promises.push(promise); promises.push(promise);
@@ -87,8 +87,25 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
this.dataSource.sort = { column: "exposedXTimes", direction: "desc" }; this.dataSource.sort = { column: "exposedXTimes", direction: "desc" };
} }
private async isPasswordExposed(cv: CipherView): Promise<ReportResult | null> {
const { login } = cv;
return await this.auditService.passwordLeaked(login.password).then((exposedCount) => {
if (exposedCount > 0) {
return { ...cv, exposedXTimes: exposedCount } as ReportResult;
}
return null;
});
}
protected canManageCipher(c: CipherView): boolean { protected canManageCipher(c: CipherView): boolean {
// this will only ever be false from the org view; // this will only ever be false from the org view;
return true; return true;
} }
async determinedUpdatedCipherReportStatus(
result: VaultItemDialogResult,
updatedCipherView: CipherView,
): Promise<CipherView | null> {
return await this.isPasswordExposed(updatedCipherView);
}
} }

View File

@@ -13,6 +13,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault";
import { VaultItemDialogResult } from "@bitwarden/web-vault/app/vault/components/vault-item-dialog/vault-item-dialog.component";
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
@@ -71,7 +72,26 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
this.filterStatus = [0]; this.filterStatus = [0];
allCiphers.forEach((ciph) => { allCiphers.forEach((ciph) => {
const { type, login, isDeleted, edit, id, viewPassword } = ciph; const [docFor2fa, isInactive2faCipher] = this.isInactive2faCipher(ciph);
if (isInactive2faCipher) {
inactive2faCiphers.push(ciph);
if (docFor2fa !== "") {
docs.set(ciph.id, docFor2fa);
}
}
});
this.filterCiphersByOrg(inactive2faCiphers);
this.cipherDocs = docs;
}
}
private isInactive2faCipher(cipher: CipherView): [string, boolean] {
let docFor2fa: string = "";
let isInactive2faCipher: boolean = false;
const { type, login, isDeleted, edit, viewPassword } = cipher;
if ( if (
type !== CipherType.Login || type !== CipherType.Login ||
(login.totp != null && login.totp !== "") || (login.totp != null && login.totp !== "") ||
@@ -80,7 +100,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
(!this.organization && !edit) || (!this.organization && !edit) ||
!viewPassword !viewPassword
) { ) {
return; return [docFor2fa, isInactive2faCipher];
} }
for (let i = 0; i < login.uris.length; i++) { for (let i = 0; i < login.uris.length; i++) {
@@ -90,20 +110,14 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
const domain = Utils.getDomain(uri); const domain = Utils.getDomain(uri);
if (domain != null && this.services.has(domain)) { if (domain != null && this.services.has(domain)) {
if (this.services.get(domain) != null) { if (this.services.get(domain) != null) {
docs.set(id, this.services.get(domain)); docFor2fa = this.services.get(domain) || "";
} }
// If the uri is in the 2fa list. Add the cipher to the inactive isInactive2faCipher = true;
// collection. No need to check any additional uris for the cipher. break;
inactive2faCiphers.push(ciph);
return;
} }
} }
} }
}); return [docFor2fa, isInactive2faCipher];
this.filterCiphersByOrg(inactive2faCiphers);
this.cipherDocs = docs;
}
} }
private async load2fa() { private async load2fa() {
@@ -142,4 +156,22 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
// this will only ever be false from the org view; // this will only ever be false from the org view;
return true; return true;
} }
async determinedUpdatedCipherReportStatus(
result: VaultItemDialogResult,
updatedCipherView: CipherView,
): Promise<CipherView | null> {
if (result === VaultItemDialogResult.Deleted) {
return null;
}
const [docFor2fa, isInactive2faCipher] = this.isInactive2faCipher(updatedCipherView);
if (isInactive2faCipher) {
this.cipherDocs.set(updatedCipherView.id, docFor2fa);
return updatedCipherView;
}
return null;
}
} }

View File

@@ -11,6 +11,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault";
import { VaultItemDialogResult } from "@bitwarden/web-vault/app/vault/components/vault-item-dialog/vault-item-dialog.component";
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
@@ -22,6 +23,7 @@ import { CipherReportComponent } from "./cipher-report.component";
standalone: false, standalone: false,
}) })
export class ReusedPasswordsReportComponent extends CipherReportComponent implements OnInit { export class ReusedPasswordsReportComponent extends CipherReportComponent implements OnInit {
ciphersToCheckForReusedPasswords: CipherView[] = [];
passwordUseMap: Map<string, number>; passwordUseMap: Map<string, number>;
disabled = true; disabled = true;
@@ -54,12 +56,19 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
} }
async setCiphers() { async setCiphers() {
const allCiphers = await this.getAllCiphers(); this.ciphersToCheckForReusedPasswords = await this.getAllCiphers();
const reusedPasswordCiphers = await this.checkCiphersForReusedPasswords(
this.ciphersToCheckForReusedPasswords,
);
this.filterCiphersByOrg(reusedPasswordCiphers);
}
protected async checkCiphersForReusedPasswords(ciphers: CipherView[]): Promise<CipherView[]> {
const ciphersWithPasswords: CipherView[] = []; const ciphersWithPasswords: CipherView[] = [];
this.passwordUseMap = new Map<string, number>(); this.passwordUseMap = new Map<string, number>();
this.filterStatus = [0]; this.filterStatus = [0];
allCiphers.forEach((ciph) => { ciphers.forEach((ciph) => {
const { type, login, isDeleted, edit, viewPassword } = ciph; const { type, login, isDeleted, edit, viewPassword } = ciph;
if ( if (
type !== CipherType.Login || type !== CipherType.Login ||
@@ -84,11 +93,46 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
this.passwordUseMap.has(c.login.password) && this.passwordUseMap.get(c.login.password) > 1, this.passwordUseMap.has(c.login.password) && this.passwordUseMap.get(c.login.password) > 1,
); );
this.filterCiphersByOrg(reusedPasswordCiphers); return reusedPasswordCiphers;
} }
protected canManageCipher(c: CipherView): boolean { protected canManageCipher(c: CipherView): boolean {
// this will only ever be false from an organization view // this will only ever be false from an organization view
return true; return true;
} }
async determinedUpdatedCipherReportStatus(
result: VaultItemDialogResult,
updatedCipherView: CipherView,
): Promise<CipherView | null> {
if (result === VaultItemDialogResult.Deleted) {
this.ciphersToCheckForReusedPasswords = this.ciphersToCheckForReusedPasswords.filter(
(c) => c.id !== updatedCipherView.id,
);
return null;
}
// recalculate the reused passwords after an update
// if a password was changed, it could affect reused counts of other ciphers
// find the cipher in our list and update it
const index = this.ciphersToCheckForReusedPasswords.findIndex(
(c) => c.id === updatedCipherView.id,
);
if (index !== -1) {
this.ciphersToCheckForReusedPasswords[index] = updatedCipherView;
}
// Re-check the passwords for reused passwords for all ciphers
const reusedPasswordCiphers = await this.checkCiphersForReusedPasswords(
this.ciphersToCheckForReusedPasswords,
);
// set the updated ciphers list to the filtered reused passwords
this.filterCiphersByOrg(reusedPasswordCiphers);
// return the updated cipher view
return updatedCipherView;
}
} }

View File

@@ -10,6 +10,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault";
import { VaultItemDialogResult } from "@bitwarden/web-vault/app/vault/components/vault-item-dialog/vault-item-dialog.component";
import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service";
@@ -93,4 +94,20 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl
// this will only ever be false from the org view; // this will only ever be false from the org view;
return true; return true;
} }
async determinedUpdatedCipherReportStatus(
result: VaultItemDialogResult,
updatedCipherView: CipherView,
): Promise<CipherView | null> {
if (result === VaultItemDialogResult.Deleted) {
return null;
}
// If the cipher still contains unsecured URIs, return it as is
if (this.cipherContainsUnsecured(updatedCipherView)) {
return updatedCipherView;
}
return null;
}
} }

View File

@@ -1,15 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
@@ -71,46 +68,26 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
this.findWeakPasswords(allCiphers); this.findWeakPasswords(allCiphers);
} }
protected async refresh(result: VaultItemDialogResult, cipher: CipherView) { async determinedUpdatedCipherReportStatus(
result: VaultItemDialogResult,
updatedCipherView: CipherView,
): Promise<CipherView | null> {
if (result === VaultItemDialogResult.Deleted) { if (result === VaultItemDialogResult.Deleted) {
// remove the cipher from the list this.weakPasswordCiphers = this.weakPasswordCiphers.filter(
this.weakPasswordCiphers = this.weakPasswordCiphers.filter((c) => c.id !== cipher.id); (c) => c.id !== updatedCipherView.id,
this.filterCiphersByOrg(this.weakPasswordCiphers); );
return; return null;
} }
if (result == VaultItemDialogResult.Saved) { const updatedReportStatus = await this.determineWeakPasswordScore(updatedCipherView);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
let updatedCipher = await this.cipherService.get(cipher.id, activeUserId);
if (this.isAdminConsoleActive) {
updatedCipher = await this.adminConsoleCipherFormConfigService.getCipher(
cipher.id as CipherId,
this.organization,
);
}
const updatedCipherView = await updatedCipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher, activeUserId),
);
// update the cipher views
const updatedReportResult = this.determineWeakPasswordScore(updatedCipherView);
const index = this.weakPasswordCiphers.findIndex((c) => c.id === updatedCipherView.id); const index = this.weakPasswordCiphers.findIndex((c) => c.id === updatedCipherView.id);
if (updatedReportResult == null) { if (index !== -1) {
// the password is no longer weak this.weakPasswordCiphers[index] = updatedReportStatus;
// remove the cipher from the list
this.weakPasswordCiphers.splice(index, 1);
this.filterCiphersByOrg(this.weakPasswordCiphers);
return;
} }
if (index > -1) { return updatedReportStatus;
// update the existing cipher
this.weakPasswordCiphers[index] = updatedReportResult;
this.filterCiphersByOrg(this.weakPasswordCiphers);
}
}
} }
protected findWeakPasswords(ciphers: CipherView[]): void { protected findWeakPasswords(ciphers: CipherView[]): void {