1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-14571] At Risk Passwords - Badge Update (#15983)

* add exclamation badge for at risk passwords on tab

* add berry icon for the badge when pending tasks are present

* remove integration wtih autofill for pending task badge

* add ability to override Never match strategy
- This is helpful for non-autofill purposes but cipher matching is still needed. This will default to the domain.

* add at-risk-cipher badge updater service

* Revert "add exclamation badge for at risk passwords on tab"

This reverts commit a9643c03d5.

* remove nullish-coalescing

* ensure that all user related observables use the same user.id

---------

Co-authored-by: Shane Melton <smelton@bitwarden.com>
This commit is contained in:
Nick Krantz
2025-09-02 15:09:20 -05:00
committed by GitHub
parent a4fca832f3
commit 5967cf0539
12 changed files with 324 additions and 4 deletions

View File

@@ -302,6 +302,7 @@ import { OffscreenStorageService } from "../platform/storage/offscreen-storage.s
import { SyncServiceListener } from "../platform/sync/sync-service.listener";
import { BrowserSystemNotificationService } from "../platform/system-notifications/browser-system-notification.service";
import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging";
import { AtRiskCipherBadgeUpdaterService } from "../vault/services/at-risk-cipher-badge-updater.service";
import CommandsBackground from "./commands.background";
import IdleBackground from "./idle.background";
@@ -433,6 +434,7 @@ export default class MainBackground {
badgeService: BadgeService;
authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService;
autofillBadgeUpdaterService: AutofillBadgeUpdaterService;
atRiskCipherUpdaterService: AtRiskCipherBadgeUpdaterService;
onUpdatedRan: boolean;
onReplacedRan: boolean;
@@ -1838,6 +1840,14 @@ export default class MainBackground {
this.logService,
);
this.atRiskCipherUpdaterService = new AtRiskCipherBadgeUpdaterService(
this.badgeService,
this.accountService,
this.cipherService,
this.logService,
this.taskService,
);
this.tabsBackground = new TabsBackground(
this,
this.notificationBackground,
@@ -1847,6 +1857,7 @@ export default class MainBackground {
await this.overlayBackground.init();
await this.tabsBackground.init();
await this.autofillBadgeUpdaterService.init();
await this.atRiskCipherUpdaterService.init();
}
generatePassword = async (): Promise<string> => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,4 +1,8 @@
export const BadgeIcon = {
Berry: {
19: "/images/berry19.png",
38: "/images/berry38.png",
},
LoggedOut: {
19: "/images/icon19_gray.png",
38: "/images/icon38_gray.png",

View File

@@ -0,0 +1,84 @@
import { BehaviorSubject } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { BadgeService } from "../../platform/badge/badge.service";
import { BadgeIcon } from "../../platform/badge/icon";
import { BadgeStatePriority } from "../../platform/badge/priority";
import { Unset } from "../../platform/badge/state";
import { BrowserApi } from "../../platform/browser/browser-api";
import { AtRiskCipherBadgeUpdaterService } from "./at-risk-cipher-badge-updater.service";
describe("AtRiskCipherBadgeUpdaterService", () => {
let service: AtRiskCipherBadgeUpdaterService;
let setState: jest.Mock;
let clearState: jest.Mock;
let warning: jest.Mock;
let getAllDecryptedForUrl: jest.Mock;
let getTab: jest.Mock;
let addListener: jest.Mock;
const activeAccount$ = new BehaviorSubject({ id: "test-account-id" });
const cipherViews$ = new BehaviorSubject([]);
const pendingTasks$ = new BehaviorSubject<SecurityTask[]>([]);
const userId = "test-user-id" as UserId;
beforeEach(async () => {
setState = jest.fn().mockResolvedValue(undefined);
clearState = jest.fn().mockResolvedValue(undefined);
warning = jest.fn();
getAllDecryptedForUrl = jest.fn().mockResolvedValue([]);
getTab = jest.fn();
addListener = jest.fn();
jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener);
jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab);
service = new AtRiskCipherBadgeUpdaterService(
{ setState, clearState } as unknown as BadgeService,
{ activeAccount$ } as unknown as AccountService,
{ cipherViews$, getAllDecryptedForUrl } as unknown as CipherService,
{ warning } as unknown as LogService,
{ pendingTasks$ } as unknown as TaskService,
);
await service.init();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("clears the tab state when there are no ciphers and no pending tasks", async () => {
const tab = { id: 1 } as chrome.tabs.Tab;
await service["setTabState"](tab, userId, []);
expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1");
});
it("sets state when there are pending tasks for the tab", async () => {
const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab;
const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask];
getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]);
await service["setTabState"](tab, userId, pendingTasks);
expect(setState).toHaveBeenCalledWith(
"at-risk-cipher-badge-3",
BadgeStatePriority.High,
{
icon: BadgeIcon.Berry,
text: Unset,
backgroundColor: Unset,
},
3,
);
});
});

View File

@@ -0,0 +1,163 @@
import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { BadgeService } from "../../platform/badge/badge.service";
import { BadgeIcon } from "../../platform/badge/icon";
import { BadgeStatePriority } from "../../platform/badge/priority";
import { Unset } from "../../platform/badge/state";
import { BrowserApi } from "../../platform/browser/browser-api";
const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`;
export class AtRiskCipherBadgeUpdaterService {
private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>();
private tabUpdated$ = new Subject<chrome.tabs.Tab>();
private tabRemoved$ = new Subject<number>();
private tabActivated$ = new Subject<chrome.tabs.Tab>();
private activeUserData$ = this.accountService.activeAccount$.pipe(
filterOutNullish(),
switchMap((user) =>
combineLatest([
of(user.id),
this.taskService
.pendingTasks$(user.id)
.pipe(
map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)),
),
this.cipherService.cipherViews$(user.id).pipe(filterOutNullish()),
]),
),
);
constructor(
private badgeService: BadgeService,
private accountService: AccountService,
private cipherService: CipherService,
private logService: LogService,
private taskService: TaskService,
) {
combineLatest({
replaced: this.tabReplaced$,
activeUserData: this.activeUserData$,
})
.pipe(
mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => {
await this.clearTabState(replaced.removedTabId);
await this.setTabState(replaced.addedTab, userId, pendingTasks);
}),
)
.subscribe(() => {});
combineLatest({
tab: this.tabActivated$,
activeUserData: this.activeUserData$,
})
.pipe(
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
await this.setTabState(tab, userId, pendingTasks);
}),
)
.subscribe();
combineLatest({
tab: this.tabUpdated$,
activeUserData: this.activeUserData$,
})
.pipe(
mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => {
await this.setTabState(tab, userId, pendingTasks);
}),
)
.subscribe();
this.tabRemoved$
.pipe(
mergeMap(async (tabId) => {
await this.clearTabState(tabId);
}),
)
.subscribe();
}
init() {
BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => {
const newTab = await BrowserApi.getTab(addedTabId);
if (!newTab) {
this.logService.warning(
`Tab replaced event received but new tab not found (id: ${addedTabId})`,
);
return;
}
this.tabReplaced$.next({
removedTabId,
addedTab: newTab,
});
});
BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => {
if (changeInfo.url) {
this.tabUpdated$.next(tab);
}
});
BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => {
const tab = await BrowserApi.getTab(activeInfo.tabId);
if (!tab) {
this.logService.warning(
`Tab activated event received but tab not found (id: ${activeInfo.tabId})`,
);
return;
}
this.tabActivated$.next(tab);
});
BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId));
}
/** Sets the pending task state for the tab */
private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) {
if (!tab.id) {
this.logService.warning("Tab event received but tab id is undefined");
return;
}
const ciphers = tab.url
? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true)
: [];
const hasPendingTasksForTab = pendingTasks.some((task) =>
ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted),
);
if (!hasPendingTasksForTab) {
await this.clearTabState(tab.id);
return;
}
await this.badgeService.setState(
StateName(tab.id),
BadgeStatePriority.High,
{
icon: BadgeIcon.Berry,
// Unset text and background color to use default badge appearance
text: Unset,
backgroundColor: Unset,
},
tab.id,
);
}
/** Clears the pending task state from a tab */
private async clearTabState(tabId: number) {
await this.badgeService.clearState(StateName(tabId));
}
}

View File

@@ -65,12 +65,16 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
userId: UserId,
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
/** When true, will override the match strategy for the cipher if it is Never. */
overrideNeverMatchStrategy?: true,
): Promise<CipherView[]>;
abstract filterCiphersForUrl<C extends CipherViewLike = CipherView>(
ciphers: C[],
url: string,
includeOtherTypes?: CipherType[],
defaultMatch?: UriMatchStrategySetting,
/** When true, will override the match strategy for the cipher if it is Never. */
overrideNeverMatchStrategy?: true,
): Promise<C[]>;
abstract getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]>;
/**

View File

@@ -111,6 +111,33 @@ describe("LoginUriView", () => {
expect(actual).toBe(false);
});
it("overrides Never match strategy with Domain when parameter is set", () => {
const loginUri = new LoginUriView();
loginUri.uri = "https://example.org";
loginUri.match = UriMatchStrategy.Never;
expect(loginUri.matchesUri("https://example.org", new Set(), undefined, true)).toBe(true);
expect(loginUri.matchesUri("https://example.org", new Set(), undefined)).toBe(false);
});
it("overrides Never match strategy when passed in as default strategy", () => {
const loginUriNoMatch = new LoginUriView();
loginUriNoMatch.uri = "https://example.org";
expect(
loginUriNoMatch.matchesUri(
"https://example.org",
new Set(),
UriMatchStrategy.Never,
true,
),
).toBe(true);
expect(
loginUriNoMatch.matchesUri("https://example.org", new Set(), UriMatchStrategy.Never),
).toBe(false);
});
});
describe("using host matching", () => {

View File

@@ -142,6 +142,8 @@ export class LoginUriView implements View {
targetUri: string,
equivalentDomains: Set<string>,
defaultUriMatch: UriMatchStrategySetting = null,
/** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */
overrideNeverMatchStrategy?: true,
): boolean {
if (!this.uri || !targetUri) {
return false;
@@ -150,6 +152,12 @@ export class LoginUriView implements View {
let matchType = this.match ?? defaultUriMatch;
matchType ??= UriMatchStrategy.Domain;
// Override the match strategy with `Domain` when it is `Never` and `overrideNeverMatchStrategy` is true.
// This is useful in scenarios when the cipher should be matched to rely other information other than autofill.
if (overrideNeverMatchStrategy && matchType === UriMatchStrategy.Never) {
matchType = UriMatchStrategy.Domain;
}
const targetDomain = Utils.getDomain(targetUri);
const matchDomains = equivalentDomains.add(targetDomain);

View File

@@ -82,12 +82,16 @@ export class LoginView extends ItemView {
targetUri: string,
equivalentDomains: Set<string>,
defaultUriMatch: UriMatchStrategySetting = null,
/** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */
overrideNeverMatchStrategy?: true,
): boolean {
if (this.uris == null) {
return false;
}
return this.uris.some((uri) => uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch));
return this.uris.some((uri) =>
uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy),
);
}
static fromJSON(obj: Partial<DeepJsonify<LoginView>>): LoginView {

View File

@@ -601,6 +601,7 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
includeOtherTypes?: CipherType[],
defaultMatch: UriMatchStrategySetting = null,
overrideNeverMatchStrategy?: true,
): Promise<CipherView[]> {
return await firstValueFrom(
this.cipherViews$(userId).pipe(
@@ -612,6 +613,7 @@ export class CipherService implements CipherServiceAbstraction {
url,
includeOtherTypes,
defaultMatch,
overrideNeverMatchStrategy,
),
),
),
@@ -623,6 +625,7 @@ export class CipherService implements CipherServiceAbstraction {
url: string,
includeOtherTypes?: CipherType[],
defaultMatch: UriMatchStrategySetting = null,
overrideNeverMatchStrategy?: true,
): Promise<C[]> {
if (url == null && includeOtherTypes == null) {
return [];
@@ -647,7 +650,13 @@ export class CipherService implements CipherServiceAbstraction {
}
if (cipherIsLogin) {
return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch);
return CipherViewLikeUtils.matchesUri(
cipher,
url,
equivalentDomains,
defaultMatch,
overrideNeverMatchStrategy,
);
}
return false;

View File

@@ -174,13 +174,19 @@ export class CipherViewLikeUtils {
targetUri: string,
equivalentDomains: Set<string>,
defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain,
overrideNeverMatchStrategy?: true,
): boolean => {
if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) {
return false;
}
if (!this.isCipherListView(cipher)) {
return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch);
return cipher.login.matchesUri(
targetUri,
equivalentDomains,
defaultUriMatch,
overrideNeverMatchStrategy,
);
}
const login = this.getLogin(cipher);
@@ -198,7 +204,7 @@ export class CipherViewLikeUtils {
});
return loginUriViews.some((uriView) =>
uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch),
uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy),
);
};