[PM-22228] Phishing events (#20065)

pull/20211/head^2
Vijay Oommen 2 months ago committed by GitHub
parent 7c49d3e996
commit 7a01b76926
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,228 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, convertToParamMap } from "@angular/router";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EventCollectionService, EventType } from "@bitwarden/common/dirt/event-logs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { MessageSender } from "@bitwarden/messaging";
import { BrowserApi } from "../../../platform/browser/browser-api";
import {
PHISHING_DETECTION_CANCEL_COMMAND,
PHISHING_DETECTION_CONTINUE_COMMAND,
} from "../services/phishing-detection.service";
import { PhishingWarning } from "./phishing-warning.component";
describe("PhishingWarning", () => {
const mockUserId = "test-user-id" as UserId;
const mockPhishingUrl = "https://phishing.example.com";
let fixture: ComponentFixture<PhishingWarning>;
let component: PhishingWarning;
let accountService: FakeAccountService;
let organizationService: ReturnType<typeof mock<OrganizationService>>;
let eventCollectionService: ReturnType<typeof mock<EventCollectionService>>;
let messageSender: ReturnType<typeof mock<MessageSender>>;
const orgWithEvents = { id: "org-1", useEvents: true, usePhishingBlocker: true } as Organization;
const orgWithoutEvents = {
id: "org-2",
useEvents: false,
usePhishingBlocker: false,
} as Organization;
beforeEach(async () => {
accountService = mockAccountServiceWith(mockUserId);
organizationService = mock<OrganizationService>();
eventCollectionService = mock<EventCollectionService>();
messageSender = mock<MessageSender>();
organizationService.organizations$.mockImplementation(() =>
of([orgWithEvents, orgWithoutEvents]),
);
eventCollectionService.collect.mockResolvedValue(undefined);
jest.spyOn(BrowserApi, "getCurrentTab").mockResolvedValue({ id: 42 } as chrome.tabs.Tab);
await TestBed.configureTestingModule({
imports: [PhishingWarning],
providers: [
{
provide: ActivatedRoute,
useValue: {
queryParamMap: of(convertToParamMap({ phishingUrl: mockPhishingUrl })),
},
},
{ provide: MessageSender, useValue: messageSender },
{ provide: EventCollectionService, useValue: eventCollectionService },
{ provide: OrganizationService, useValue: organizationService },
{ provide: AccountService, useValue: accountService },
{ provide: I18nService, useValue: { t: jest.fn((key: string) => key) } },
],
}).compileComponents();
fixture = TestBed.createComponent(PhishingWarning);
component = fixture.componentInstance;
});
describe("ngOnInit", () => {
it("collects PhishingBlocker_SiteAccessed for each org with useEvents", async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(eventCollectionService.collect).toHaveBeenCalledWith(
EventType.PhishingBlocker_SiteAccessed,
undefined,
false,
"org-1",
);
expect(eventCollectionService.collect).not.toHaveBeenCalledWith(
EventType.PhishingBlocker_SiteAccessed,
undefined,
false,
"org-2",
);
expect(eventCollectionService.collect).toHaveBeenCalledTimes(1);
});
it("does not collect events when no orgs have useEvents", async () => {
organizationService.organizations$.mockImplementation(() => of([orgWithoutEvents]));
fixture.detectChanges();
await fixture.whenStable();
expect(eventCollectionService.collect).not.toHaveBeenCalled();
});
});
describe("closeTab", () => {
it("collects PhishingBlocker_SiteExited for each org with useEvents before closing", async () => {
fixture.detectChanges();
await fixture.whenStable();
eventCollectionService.collect.mockClear();
await component.closeTab();
expect(eventCollectionService.collect).toHaveBeenCalledWith(
EventType.PhishingBlocker_SiteExited,
undefined,
false,
"org-1",
);
expect(eventCollectionService.collect).not.toHaveBeenCalledWith(
EventType.PhishingBlocker_SiteExited,
undefined,
false,
"org-2",
);
expect(eventCollectionService.collect).toHaveBeenCalledTimes(1);
expect(messageSender.send).toHaveBeenCalledWith(PHISHING_DETECTION_CANCEL_COMMAND, {
tabId: 42,
});
});
});
describe("continueAnyway", () => {
it("collects PhishingBlocker_Bypassed for each org with useEvents", async () => {
fixture.detectChanges();
await fixture.whenStable();
eventCollectionService.collect.mockClear();
await component.continueAnyway();
expect(eventCollectionService.collect).toHaveBeenCalledWith(
EventType.PhishingBlocker_Bypassed,
undefined,
true,
"org-1",
);
expect(eventCollectionService.collect).not.toHaveBeenCalledWith(
EventType.PhishingBlocker_Bypassed,
undefined,
true,
"org-2",
);
expect(eventCollectionService.collect).toHaveBeenCalledTimes(1);
expect(messageSender.send).toHaveBeenCalledWith(PHISHING_DETECTION_CONTINUE_COMMAND, {
tabId: 42,
url: mockPhishingUrl,
});
});
});
describe("getOrgsToNotify", () => {
it("filters organizations by useEvents and usePhishingBlocker", async () => {
const orgWithBoth = {
id: "org-1",
useEvents: true,
usePhishingBlocker: true,
} as Organization;
const orgWithoutEvents = {
id: "org-2",
useEvents: false,
usePhishingBlocker: true,
} as Organization;
const orgWithoutPhishingBlocker = {
id: "org-3",
useEvents: true,
usePhishingBlocker: false,
} as Organization;
const orgWithNeither = {
id: "org-4",
useEvents: false,
usePhishingBlocker: false,
} as Organization;
organizationService.organizations$.mockImplementation(() =>
of([orgWithBoth, orgWithoutEvents, orgWithoutPhishingBlocker, orgWithNeither]),
);
fixture.detectChanges();
const result = await fixture.componentInstance["getOrgsToNotify"]();
expect(result).toEqual([orgWithBoth]);
});
it("returns empty array when no orgs have both useEvents and usePhishingBlocker", async () => {
const orgWithOnlyEvents = {
id: "org-1",
useEvents: true,
usePhishingBlocker: false,
} as Organization;
const orgWithOnlyPhishingBlocker = {
id: "org-2",
useEvents: false,
usePhishingBlocker: true,
} as Organization;
organizationService.organizations$.mockImplementation(() =>
of([orgWithOnlyEvents, orgWithOnlyPhishingBlocker]),
);
fixture.detectChanges();
const result = await fixture.componentInstance["getOrgsToNotify"]();
expect(result).toEqual([]);
});
it("returns all orgs when all have useEvents and usePhishingBlocker", async () => {
const orgs = [
{ id: "org-1", useEvents: true, usePhishingBlocker: true } as Organization,
{ id: "org-2", useEvents: true, usePhishingBlocker: true } as Organization,
];
organizationService.organizations$.mockImplementation(() => of(orgs));
fixture.detectChanges();
const result = await fixture.componentInstance["getOrgsToNotify"]();
expect(result).toEqual(orgs);
});
});
});

@ -1,10 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { Component, inject, OnInit } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EventCollectionService, EventType } from "@bitwarden/common/dirt/event-logs";
import {
AsyncActionsModule,
ButtonModule,
@ -17,6 +21,7 @@ import {
TypographyModule,
} from "@bitwarden/components";
import { MessageSender } from "@bitwarden/messaging";
import { I18nPipe } from "@bitwarden/ui-common";
import {
PHISHING_DETECTION_CANCEL_COMMAND,
@ -32,7 +37,6 @@ import {
imports: [
CommonModule,
SvgModule,
JslibModule,
LinkModule,
FormFieldModule,
AsyncActionsModule,
@ -42,32 +46,57 @@ import {
IconTileComponent,
CalloutComponent,
TypographyModule,
I18nPipe,
],
})
// FIXME(https://bitwarden.atlassian.net/browse/PM-28231): Use Component suffix
// eslint-disable-next-line @angular-eslint/component-class-suffix
export class PhishingWarning {
export class PhishingWarning implements OnInit {
private activatedRoute = inject(ActivatedRoute);
private messageSender = inject(MessageSender);
private eventCollectionService = inject(EventCollectionService);
private organizationService = inject(OrganizationService);
private accountService = inject(AccountService);
private phishingUrl$ = this.activatedRoute.queryParamMap.pipe(
map((params) => params.get("phishingUrl") || ""),
);
protected phishingHostname$ = this.phishingUrl$.pipe(map((url) => new URL(url).hostname));
async ngOnInit() {
await this.recordEvents(EventType.PhishingBlocker_SiteAccessed, false);
}
async closeTab() {
await this.recordEvents(EventType.PhishingBlocker_SiteExited, false);
const tabId = await this.getTabId();
this.messageSender.send(PHISHING_DETECTION_CANCEL_COMMAND, {
tabId,
});
this.messageSender.send(PHISHING_DETECTION_CANCEL_COMMAND, { tabId });
}
async continueAnyway() {
await this.recordEvents(EventType.PhishingBlocker_Bypassed, true);
const url = await firstValueFrom(this.phishingUrl$);
const tabId = await this.getTabId();
this.messageSender.send(PHISHING_DETECTION_CONTINUE_COMMAND, {
tabId,
url,
});
this.messageSender.send(PHISHING_DETECTION_CONTINUE_COMMAND, { tabId, url });
}
private async recordEvents(eventType: EventType, uploadImmediately: boolean): Promise<void> {
try {
const orgs = await this.getOrgsToNotify();
// keep this sequential, using a Promise.all causes a race condition
for (const org of orgs) {
await this.eventCollectionService.collect(eventType, undefined, uploadImmediately, org.id);
}
} catch {
// Event collection failure should not block the user action
}
}
private async getOrgsToNotify(): Promise<Organization[]> {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const orgs = await firstValueFrom(this.organizationService.organizations$(userId));
return orgs.filter((o) => o.useEvents && o.usePhishingBlocker);
}
private async getTabId() {

@ -3,10 +3,14 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { BehaviorSubject, of } from "rxjs";
import { DeactivatedOrg } from "@bitwarden/assets/svg";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EventCollectionService } from "@bitwarden/common/dirt/event-logs";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components";
import { MessageSender } from "@bitwarden/messaging";
@ -87,6 +91,24 @@ export default {
},
},
mockActivatedRoute({ phishingUrl: "http://malicious-example.com" }),
{
provide: EventCollectionService,
useValue: {
collect: () => Promise.resolve(),
} as Partial<EventCollectionService>,
},
{
provide: AccountService,
useValue: {
activeAccount$: of({ id: "test-user-id" as UserId }),
} as Partial<AccountService>,
},
{
provide: OrganizationService,
useValue: {
organizations$: () => of([]),
} as Partial<OrganizationService>,
},
],
}),
],

@ -698,6 +698,19 @@ export class EventService {
this.formatServiceAccountId(ev, options),
);
break;
case EventType.PhishingBlocker_SiteAccessed:
msg = this.i18nService.t("phishingBlockerSiteAccessed");
humanReadableMsg = this.i18nService.t("phishingBlockerSiteAccessed");
break;
case EventType.PhishingBlocker_SiteExited:
msg = this.i18nService.t("phishingBlockerSiteExited");
humanReadableMsg = this.i18nService.t("phishingBlockerSiteExited");
break;
case EventType.PhishingBlocker_Bypassed:
msg = this.i18nService.t("phishingBlockerBypassed");
humanReadableMsg = this.i18nService.t("phishingBlockerBypassed");
break;
default:
break;
}

@ -9570,6 +9570,15 @@
}
}
},
"phishingBlockerSiteAccessed": {
"message": "User attempted to access known phishing site"
},
"phishingBlockerSiteExited": {
"message": "User exited phishing warning page"
},
"phishingBlockerBypassed": {
"message": "User bypassed phishing warning"
},
"sdk": {
"message": "SDK",
"description": "Software Development Kit"

@ -124,4 +124,8 @@ export enum EventType {
ServiceAccount_GroupRemoved = 2303,
ServiceAccount_Created = 2304,
ServiceAccount_Deleted = 2305,
PhishingBlocker_SiteAccessed = 2400,
PhishingBlocker_SiteExited = 2401,
PhishingBlocker_Bypassed = 2402,
}

Loading…
Cancel
Save