mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-26944] phishing data checksum diffing + daily patches (#16983)
* expose local db file to extension
* fetch from local db as fallback; only fetch new data on changed checksum; fetch from cdn
* check for undefined chrome runtime (for easy Storybook mocking)
* update capital letters lint
* add audit api tests
* add bash script to fetch local db info and split it to meet FF size limits
* add readme
* Rename README.md to readme.md
* remove leftover file
* remove unused methods from audit service
* remove local db logic
* wip
* revert local db build changes
* add tests; sub to updates directly; refactor teardown logic
* fix eslint crashing
* remove temp premium override
* remove unused test
* update timer value
* run prettier
* refetch all domains on app version change
* fix log statement
* harden fetching
* filter empty domains
* fix type issue
* fix typo
* fix type error
* fix cleanup
(cherry picked from commit 7ac6a67835)
This commit is contained in:
committed by
William Martin
parent
b4420d770e
commit
a1580f8aea
@@ -293,6 +293,7 @@ import { AutofillBadgeUpdaterService } from "../autofill/services/autofill-badge
|
||||
import AutofillService from "../autofill/services/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service";
|
||||
import { SafariApp } from "../browser/safariApp";
|
||||
import { PhishingDataService } from "../dirt/phishing-detection/services/phishing-data.service";
|
||||
import { PhishingDetectionService } from "../dirt/phishing-detection/services/phishing-detection.service";
|
||||
import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service";
|
||||
import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service";
|
||||
@@ -491,6 +492,9 @@ export default class MainBackground {
|
||||
private popupViewCacheBackgroundService: PopupViewCacheBackgroundService;
|
||||
private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService;
|
||||
|
||||
// DIRT
|
||||
private phishingDataService: PhishingDataService;
|
||||
|
||||
constructor() {
|
||||
// Services
|
||||
const lockedCallback = async (userId: UserId) => {
|
||||
@@ -1451,15 +1455,20 @@ export default class MainBackground {
|
||||
|
||||
this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
|
||||
|
||||
this.phishingDataService = new PhishingDataService(
|
||||
this.apiService,
|
||||
this.taskSchedulerService,
|
||||
this.globalStateProvider,
|
||||
this.logService,
|
||||
this.platformUtilsService,
|
||||
);
|
||||
|
||||
PhishingDetectionService.initialize(
|
||||
this.accountService,
|
||||
this.auditService,
|
||||
this.billingAccountProfileStateService,
|
||||
this.configService,
|
||||
this.eventCollectionService,
|
||||
this.logService,
|
||||
this.storageService,
|
||||
this.taskSchedulerService,
|
||||
this.phishingDataService,
|
||||
);
|
||||
|
||||
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
DefaultTaskSchedulerService,
|
||||
TaskSchedulerService,
|
||||
} from "@bitwarden/common/platform/scheduling";
|
||||
import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { PhishingDataService, PhishingData, PHISHING_DOMAINS_KEY } from "./phishing-data.service";
|
||||
|
||||
describe("PhishingDataService", () => {
|
||||
let service: PhishingDataService;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let taskSchedulerService: TaskSchedulerService;
|
||||
let logService: MockProxy<LogService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
const stateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
const setMockState = (state: PhishingData) => {
|
||||
stateProvider.getFake(PHISHING_DOMAINS_KEY).stateSubject.next(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
let fetchChecksumSpy: jest.SpyInstance;
|
||||
let fetchDomainsSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
apiService = mock<ApiService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0");
|
||||
|
||||
taskSchedulerService = new DefaultTaskSchedulerService(logService);
|
||||
|
||||
service = new PhishingDataService(
|
||||
apiService,
|
||||
taskSchedulerService,
|
||||
stateProvider,
|
||||
logService,
|
||||
platformUtilsService,
|
||||
);
|
||||
|
||||
fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingDomainsChecksum");
|
||||
fetchDomainsSpy = jest.spyOn(service as any, "fetchPhishingDomains");
|
||||
});
|
||||
|
||||
describe("isPhishingDomains", () => {
|
||||
it("should detect a phishing domain", async () => {
|
||||
setMockState({
|
||||
domains: ["phish.com", "badguy.net"],
|
||||
timestamp: Date.now(),
|
||||
checksum: "abc123",
|
||||
applicationVersion: "1.0.0",
|
||||
});
|
||||
const url = new URL("http://phish.com");
|
||||
const result = await service.isPhishingDomain(url);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should not detect a safe domain", async () => {
|
||||
setMockState({
|
||||
domains: ["phish.com", "badguy.net"],
|
||||
timestamp: Date.now(),
|
||||
checksum: "abc123",
|
||||
applicationVersion: "1.0.0",
|
||||
});
|
||||
const url = new URL("http://safe.com");
|
||||
const result = await service.isPhishingDomain(url);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should match against root domain", async () => {
|
||||
setMockState({
|
||||
domains: ["phish.com", "badguy.net"],
|
||||
timestamp: Date.now(),
|
||||
checksum: "abc123",
|
||||
applicationVersion: "1.0.0",
|
||||
});
|
||||
const url = new URL("http://phish.com/about");
|
||||
const result = await service.isPhishingDomain(url);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should not error on empty state", async () => {
|
||||
setMockState(undefined as any);
|
||||
const url = new URL("http://phish.com/about");
|
||||
const result = await service.isPhishingDomain(url);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextDomains", () => {
|
||||
it("refetches all domains if applicationVersion has changed", async () => {
|
||||
const prev: PhishingData = {
|
||||
domains: ["a.com"],
|
||||
timestamp: Date.now() - 60000,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("new");
|
||||
fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]);
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0");
|
||||
|
||||
const result = await service.getNextDomains(prev);
|
||||
|
||||
expect(result!.domains).toEqual(["d.com", "e.com"]);
|
||||
expect(result!.checksum).toBe("new");
|
||||
expect(result!.applicationVersion).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("only updates timestamp if checksum matches", async () => {
|
||||
const prev: PhishingData = {
|
||||
domains: ["a.com"],
|
||||
timestamp: Date.now() - 60000,
|
||||
checksum: "abc",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("abc");
|
||||
const result = await service.getNextDomains(prev);
|
||||
expect(result!.domains).toEqual(prev.domains);
|
||||
expect(result!.checksum).toBe("abc");
|
||||
expect(result!.timestamp).not.toBe(prev.timestamp);
|
||||
});
|
||||
|
||||
it("patches daily domains if cache is fresh", async () => {
|
||||
const prev: PhishingData = {
|
||||
domains: ["a.com"],
|
||||
timestamp: Date.now() - 60000,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("new");
|
||||
fetchDomainsSpy.mockResolvedValue(["b.com", "c.com"]);
|
||||
const result = await service.getNextDomains(prev);
|
||||
expect(result!.domains).toEqual(["a.com", "b.com", "c.com"]);
|
||||
expect(result!.checksum).toBe("new");
|
||||
});
|
||||
|
||||
it("fetches all domains if cache is old", async () => {
|
||||
const prev: PhishingData = {
|
||||
domains: ["a.com"],
|
||||
timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("new");
|
||||
fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]);
|
||||
const result = await service.getNextDomains(prev);
|
||||
expect(result!.domains).toEqual(["d.com", "e.com"]);
|
||||
expect(result!.checksum).toBe("new");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
first,
|
||||
firstValueFrom,
|
||||
map,
|
||||
retry,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ScheduledTaskNames, TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bitwarden/state";
|
||||
|
||||
export type PhishingData = {
|
||||
domains: string[];
|
||||
timestamp: number;
|
||||
checksum: string;
|
||||
|
||||
/**
|
||||
* We store the application version to refetch the entire dataset on a new client release.
|
||||
* This counteracts daily appends updates not removing inactive or false positive domains.
|
||||
*/
|
||||
applicationVersion: string;
|
||||
};
|
||||
|
||||
export const PHISHING_DOMAINS_KEY = new KeyDefinition<PhishingData>(
|
||||
PHISHING_DETECTION_DISK,
|
||||
"phishingDomains",
|
||||
{
|
||||
deserializer: (value: PhishingData) =>
|
||||
value ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" },
|
||||
},
|
||||
);
|
||||
|
||||
/** Coordinates fetching, caching, and patching of known phishing domains */
|
||||
export class PhishingDataService {
|
||||
private static readonly RemotePhishingDatabaseUrl =
|
||||
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt";
|
||||
private static readonly RemotePhishingDatabaseChecksumUrl =
|
||||
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5";
|
||||
private static readonly RemotePhishingDatabaseTodayUrl =
|
||||
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-domains-NEW-today.txt";
|
||||
|
||||
private _testDomains = this.getTestDomains();
|
||||
private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY);
|
||||
private _domains$ = this._cachedState.state$.pipe(
|
||||
map(
|
||||
(state) =>
|
||||
new Set(
|
||||
(state?.domains?.filter((line) => line.trim().length > 0) ?? []).concat(
|
||||
this._testDomains,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// How often are new domains added to the remote?
|
||||
readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
private _triggerUpdate$ = new Subject<void>();
|
||||
update$ = this._triggerUpdate$.pipe(
|
||||
startWith(), // Always emit once
|
||||
tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)),
|
||||
switchMap(() =>
|
||||
this._cachedState.state$.pipe(
|
||||
first(), // Only take the first value to avoid an infinite loop when updating the cache below
|
||||
switchMap(async (cachedState) => {
|
||||
const next = await this.getNextDomains(cachedState);
|
||||
if (next) {
|
||||
await this._cachedState.update(() => next);
|
||||
this.logService.info(`[PhishingDataService] cache updated`);
|
||||
}
|
||||
}),
|
||||
retry({
|
||||
count: 3,
|
||||
delay: (err, count) => {
|
||||
this.logService.error(
|
||||
`[PhishingDataService] Unable to update domains. Attempt ${count}.`,
|
||||
err,
|
||||
);
|
||||
return timer(5 * 60 * 1000); // 5 minutes
|
||||
},
|
||||
resetOnSuccess: true,
|
||||
}),
|
||||
catchError(
|
||||
(
|
||||
err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */,
|
||||
) => {
|
||||
this.logService.error(
|
||||
"[PhishingDataService] Retries unsuccessful. Unable to update domains.",
|
||||
err,
|
||||
);
|
||||
return EMPTY;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
private logService: LogService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => {
|
||||
this._triggerUpdate$.next();
|
||||
});
|
||||
this.taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.phishingDomainUpdate,
|
||||
this.UPDATE_INTERVAL_DURATION,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URL is a known phishing domain
|
||||
*
|
||||
* @param url The URL to check
|
||||
* @returns True if the URL is a known phishing domain, false otherwise
|
||||
*/
|
||||
async isPhishingDomain(url: URL): Promise<boolean> {
|
||||
const domains = await firstValueFrom(this._domains$);
|
||||
const result = domains.has(url.hostname);
|
||||
if (result) {
|
||||
this.logService.debug("[PhishingDataService] Caught phishing domain:", url.hostname);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async getNextDomains(prev: PhishingData | null): Promise<PhishingData | null> {
|
||||
prev = prev ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" };
|
||||
const timestamp = Date.now();
|
||||
const prevAge = timestamp - prev.timestamp;
|
||||
this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`);
|
||||
|
||||
const applicationVersion = await this.platformUtilsService.getApplicationVersion();
|
||||
|
||||
// If checksum matches, return existing data with new timestamp & version
|
||||
const remoteChecksum = await this.fetchPhishingDomainsChecksum();
|
||||
if (remoteChecksum && prev.checksum === remoteChecksum) {
|
||||
this.logService.info(
|
||||
`[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`,
|
||||
);
|
||||
return { ...prev, timestamp, applicationVersion };
|
||||
}
|
||||
// Checksum is different, data needs to be updated.
|
||||
|
||||
// Approach 1: Fetch only new domains and append
|
||||
const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION;
|
||||
if (isOneDayOldMax && applicationVersion === prev.applicationVersion) {
|
||||
const dailyDomains: string[] = await this.fetchPhishingDomains(
|
||||
PhishingDataService.RemotePhishingDatabaseTodayUrl,
|
||||
);
|
||||
this.logService.info(
|
||||
`[PhishingDataService] ${dailyDomains.length} new phishing domains added`,
|
||||
);
|
||||
return {
|
||||
domains: prev.domains.concat(dailyDomains),
|
||||
checksum: remoteChecksum,
|
||||
timestamp,
|
||||
applicationVersion,
|
||||
};
|
||||
}
|
||||
|
||||
// Approach 2: Fetch all domains
|
||||
const domains = await this.fetchPhishingDomains(PhishingDataService.RemotePhishingDatabaseUrl);
|
||||
return {
|
||||
domains,
|
||||
timestamp,
|
||||
checksum: remoteChecksum,
|
||||
applicationVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchPhishingDomainsChecksum() {
|
||||
const response = await this.apiService.nativeFetch(
|
||||
new Request(PhishingDataService.RemotePhishingDatabaseChecksumUrl),
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
private async fetchPhishingDomains(url: string) {
|
||||
const response = await this.apiService.nativeFetch(new Request(url));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`[PhishingDataService] Failed to fetch domains: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.text().then((text) => text.split("\n"));
|
||||
}
|
||||
|
||||
private getTestDomains() {
|
||||
const flag = devFlagEnabled("testPhishingUrls");
|
||||
if (!flag) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const domains = devFlagValue("testPhishingUrls") as unknown[];
|
||||
if (domains && domains instanceof Array) {
|
||||
this.logService.debug(
|
||||
"[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:",
|
||||
domains,
|
||||
);
|
||||
return domains as string[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,36 @@
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service";
|
||||
|
||||
import { PhishingDataService } from "./phishing-data.service";
|
||||
import { PhishingDetectionService } from "./phishing-detection.service";
|
||||
|
||||
describe("PhishingDetectionService", () => {
|
||||
let accountService: AccountService;
|
||||
let auditService: AuditService;
|
||||
let billingAccountProfileStateService: BillingAccountProfileStateService;
|
||||
let configService: ConfigService;
|
||||
let eventCollectionService: EventCollectionService;
|
||||
let logService: LogService;
|
||||
let storageService: AbstractStorageService;
|
||||
let taskSchedulerService: TaskSchedulerService;
|
||||
let phishingDataService: PhishingDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = { getAccount$: jest.fn(() => of(null)) } as any;
|
||||
auditService = { getKnownPhishingDomains: jest.fn() } as any;
|
||||
billingAccountProfileStateService = {} as any;
|
||||
configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any;
|
||||
eventCollectionService = {} as any;
|
||||
logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any;
|
||||
storageService = { get: jest.fn(), save: jest.fn() } as any;
|
||||
taskSchedulerService = { registerTaskHandler: jest.fn(), setInterval: jest.fn() } as any;
|
||||
phishingDataService = {} as any;
|
||||
});
|
||||
|
||||
it("should initialize without errors", () => {
|
||||
expect(() => {
|
||||
PhishingDetectionService.initialize(
|
||||
accountService,
|
||||
auditService,
|
||||
billingAccountProfileStateService,
|
||||
configService,
|
||||
eventCollectionService,
|
||||
logService,
|
||||
storageService,
|
||||
taskSchedulerService,
|
||||
phishingDataService,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
@@ -66,13 +54,10 @@ describe("PhishingDetectionService", () => {
|
||||
// Run the initialization
|
||||
PhishingDetectionService.initialize(
|
||||
accountService,
|
||||
auditService,
|
||||
billingAccountProfileStateService,
|
||||
configService,
|
||||
eventCollectionService,
|
||||
logService,
|
||||
storageService,
|
||||
taskSchedulerService,
|
||||
phishingDataService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,23 +90,10 @@ describe("PhishingDetectionService", () => {
|
||||
// Run the initialization
|
||||
PhishingDetectionService.initialize(
|
||||
accountService,
|
||||
auditService,
|
||||
billingAccountProfileStateService,
|
||||
configService,
|
||||
eventCollectionService,
|
||||
logService,
|
||||
storageService,
|
||||
taskSchedulerService,
|
||||
phishingDataService,
|
||||
);
|
||||
});
|
||||
|
||||
it("should detect phishing domains", () => {
|
||||
PhishingDetectionService["_knownPhishingDomains"].add("phishing.com");
|
||||
const url = new URL("https://phishing.com");
|
||||
expect(PhishingDetectionService.isPhishingDomain(url)).toBe(true);
|
||||
const safeUrl = new URL("https://safe.com");
|
||||
expect(PhishingDetectionService.isPhishingDomain(safeUrl)).toBe(false);
|
||||
});
|
||||
|
||||
// Add more tests for other methods as needed
|
||||
});
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import {
|
||||
combineLatest,
|
||||
concatMap,
|
||||
delay,
|
||||
EMPTY,
|
||||
map,
|
||||
Subject,
|
||||
Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { combineLatest, concatMap, delay, EMPTY, map, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { devFlagEnabled, devFlagValue } from "@bitwarden/common/platform/misc/flags";
|
||||
import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
|
||||
import { PhishingDataService } from "./phishing-data.service";
|
||||
import {
|
||||
CaughtPhishingDomain,
|
||||
isPhishingDetectionMessage,
|
||||
@@ -32,39 +18,23 @@ import {
|
||||
} from "./phishing-detection.types";
|
||||
|
||||
export class PhishingDetectionService {
|
||||
private static readonly _UPDATE_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
private static readonly _RETRY_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
private static readonly _MAX_RETRIES = 3;
|
||||
private static readonly _STORAGE_KEY = "phishing_domains_cache";
|
||||
private static _auditService: AuditService;
|
||||
private static _destroy$ = new Subject<void>();
|
||||
|
||||
private static _logService: LogService;
|
||||
private static _storageService: AbstractStorageService;
|
||||
private static _taskSchedulerService: TaskSchedulerService;
|
||||
private static _updateCacheSubscription: Subscription | null = null;
|
||||
private static _retrySubscription: Subscription | null = null;
|
||||
private static _phishingDataService: PhishingDataService;
|
||||
|
||||
private static _navigationEventsSubject = new Subject<PhishingDetectionNavigationEvent>();
|
||||
private static _navigationEvents: Subscription | null = null;
|
||||
private static _knownPhishingDomains = new Set<string>();
|
||||
private static _caughtTabs: Map<PhishingDetectionTabId, CaughtPhishingDomain> = new Map();
|
||||
private static _isInitialized = false;
|
||||
private static _isUpdating = false;
|
||||
private static _retryCount = 0;
|
||||
private static _lastUpdateTime: number = 0;
|
||||
|
||||
static initialize(
|
||||
accountService: AccountService,
|
||||
auditService: AuditService,
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
configService: ConfigService,
|
||||
eventCollectionService: EventCollectionService,
|
||||
logService: LogService,
|
||||
storageService: AbstractStorageService,
|
||||
taskSchedulerService: TaskSchedulerService,
|
||||
phishingDataService: PhishingDataService,
|
||||
): void {
|
||||
this._auditService = auditService;
|
||||
this._logService = logService;
|
||||
this._storageService = storageService;
|
||||
this._taskSchedulerService = taskSchedulerService;
|
||||
this._phishingDataService = phishingDataService;
|
||||
|
||||
logService.info("[PhishingDetectionService] Initialize called. Checking prerequisites...");
|
||||
|
||||
@@ -98,21 +68,6 @@ export class PhishingDetectionService {
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URL is a known phishing domain
|
||||
*
|
||||
* @param url The URL to check
|
||||
* @returns True if the URL is a known phishing domain, false otherwise
|
||||
*/
|
||||
static isPhishingDomain(url: URL): boolean {
|
||||
const result = this._knownPhishingDomains.has(url.hostname);
|
||||
if (result) {
|
||||
this._logService.debug("[PhishingDetectionService] Caught phishing domain:", url.hostname);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the phishing detection service to close the warning page
|
||||
*/
|
||||
@@ -146,45 +101,12 @@ export class PhishingDetectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the phishing detection service, setting up listeners and registering tasks
|
||||
*/
|
||||
private static async _setup(): Promise<void> {
|
||||
if (this._isInitialized) {
|
||||
this._logService.info("[PhishingDetectionService] Already initialized, skipping setup.");
|
||||
return;
|
||||
}
|
||||
|
||||
this._isInitialized = true;
|
||||
this._setupListeners();
|
||||
|
||||
// Register the update task
|
||||
this._taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.phishingDomainUpdate,
|
||||
async () => {
|
||||
try {
|
||||
await this._fetchKnownPhishingDomains();
|
||||
} catch (error) {
|
||||
this._logService.error(
|
||||
"[PhishingDetectionService] Failed to update phishing domains in task handler:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Initial load of cached domains
|
||||
await this._loadCachedDomains();
|
||||
|
||||
// Set up periodic updates every 24 hours
|
||||
this._setupPeriodicUpdates();
|
||||
this._logService.debug("[PhishingDetectionService] Phishing detection feature is initialized.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up listeners for messages from the web page and web navigation events
|
||||
*/
|
||||
private static _setupListeners(): void {
|
||||
private static _setup(): void {
|
||||
this._phishingDataService.update$.pipe(takeUntil(this._destroy$)).subscribe();
|
||||
|
||||
// Setup listeners from web page/content script
|
||||
BrowserApi.addListener(chrome.runtime.onMessage, this._handleExtensionMessage.bind(this));
|
||||
BrowserApi.addListener(chrome.tabs.onReplaced, this._handleReplacementEvent.bind(this));
|
||||
@@ -192,9 +114,10 @@ export class PhishingDetectionService {
|
||||
|
||||
// When a navigation event occurs, check if a replace event for the same tabId exists,
|
||||
// and call the replace handler before handling navigation.
|
||||
this._navigationEvents = this._navigationEventsSubject
|
||||
this._navigationEventsSubject
|
||||
.pipe(
|
||||
delay(100), // Delay slightly to allow replace events to be caught
|
||||
takeUntil(this._destroy$),
|
||||
)
|
||||
.subscribe(({ tabId, changeInfo, tab }) => {
|
||||
void this._processNavigation(tabId, changeInfo, tab);
|
||||
@@ -271,7 +194,7 @@ export class PhishingDetectionService {
|
||||
}
|
||||
|
||||
// Check if tab is navigating to a phishing url and handle navigation
|
||||
this._checkTabForPhishing(tabId, new URL(tab.url));
|
||||
await this._checkTabForPhishing(tabId, new URL(tab.url));
|
||||
await this._handleTabNavigation(tabId);
|
||||
}
|
||||
|
||||
@@ -371,11 +294,11 @@ export class PhishingDetectionService {
|
||||
* @param tabId Tab to check for phishing domain
|
||||
* @param url URL of the tab to check
|
||||
*/
|
||||
private static _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) {
|
||||
private static async _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) {
|
||||
// Check if the tab already being tracked
|
||||
const caughtTab = this._caughtTabs.get(tabId);
|
||||
|
||||
const isPhishing = this.isPhishingDomain(url);
|
||||
const isPhishing = await this._phishingDataService.isPhishingDomain(url);
|
||||
this._logService.debug(
|
||||
`[PhishingDetectionService] Checking for phishing url. Result: ${isPhishing} on ${url}`,
|
||||
);
|
||||
@@ -458,237 +381,16 @@ export class PhishingDetectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up periodic updates for phishing domains
|
||||
*/
|
||||
private static _setupPeriodicUpdates() {
|
||||
// Clean up any existing subscriptions
|
||||
if (this._updateCacheSubscription) {
|
||||
this._updateCacheSubscription.unsubscribe();
|
||||
}
|
||||
if (this._retrySubscription) {
|
||||
this._retrySubscription.unsubscribe();
|
||||
}
|
||||
|
||||
this._updateCacheSubscription = this._taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.phishingDomainUpdate,
|
||||
this._UPDATE_INTERVAL,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a retry for updating phishing domains if the update fails
|
||||
*/
|
||||
private static _scheduleRetry() {
|
||||
// If we've exceeded max retries, stop retrying
|
||||
if (this._retryCount >= this._MAX_RETRIES) {
|
||||
this._logService.warning(
|
||||
`[PhishingDetectionService] Max retries (${this._MAX_RETRIES}) reached for phishing domain update. Will try again in ${this._UPDATE_INTERVAL / (1000 * 60 * 60)} hours.`,
|
||||
);
|
||||
this._retryCount = 0;
|
||||
if (this._retrySubscription) {
|
||||
this._retrySubscription.unsubscribe();
|
||||
this._retrySubscription = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up existing retry subscription if any
|
||||
if (this._retrySubscription) {
|
||||
this._retrySubscription.unsubscribe();
|
||||
}
|
||||
|
||||
// Increment retry count
|
||||
this._retryCount++;
|
||||
|
||||
// Schedule a retry in 5 minutes
|
||||
this._retrySubscription = this._taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.phishingDomainUpdate,
|
||||
this._RETRY_INTERVAL,
|
||||
);
|
||||
|
||||
this._logService.info(
|
||||
`[PhishingDetectionService] Scheduled retry ${this._retryCount}/${this._MAX_RETRIES} for phishing domain update in ${this._RETRY_INTERVAL / (1000 * 60)} minutes`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles adding test phishing URLs from dev flags for testing purposes
|
||||
*/
|
||||
private static _handleTestUrls() {
|
||||
if (devFlagEnabled("testPhishingUrls")) {
|
||||
const testPhishingUrls = devFlagValue("testPhishingUrls");
|
||||
this._logService.debug(
|
||||
"[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:",
|
||||
testPhishingUrls,
|
||||
);
|
||||
if (testPhishingUrls && testPhishingUrls instanceof Array) {
|
||||
testPhishingUrls.forEach((domain) => {
|
||||
if (domain && typeof domain === "string") {
|
||||
this._knownPhishingDomains.add(domain);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads cached phishing domains from storage
|
||||
* If no cache exists or it is expired, fetches the latest domains
|
||||
*/
|
||||
private static async _loadCachedDomains() {
|
||||
try {
|
||||
const cachedData = await this._storageService.get<{ domains: string[]; timestamp: number }>(
|
||||
this._STORAGE_KEY,
|
||||
);
|
||||
if (cachedData) {
|
||||
this._logService.info("[PhishingDetectionService] Phishing cachedData exists");
|
||||
const phishingDomains = cachedData.domains || [];
|
||||
|
||||
this._setKnownPhishingDomains(phishingDomains);
|
||||
this._handleTestUrls();
|
||||
}
|
||||
|
||||
// If cache is empty or expired, trigger an immediate update
|
||||
if (
|
||||
this._knownPhishingDomains.size === 0 ||
|
||||
Date.now() - this._lastUpdateTime >= this._UPDATE_INTERVAL
|
||||
) {
|
||||
await this._fetchKnownPhishingDomains();
|
||||
}
|
||||
} catch (error) {
|
||||
this._logService.error(
|
||||
"[PhishingDetectionService] Failed to load cached phishing domains:",
|
||||
error,
|
||||
);
|
||||
this._handleTestUrls();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest known phishing domains from the audit service
|
||||
* Updates the cache and handles retries if necessary
|
||||
*/
|
||||
static async _fetchKnownPhishingDomains(): Promise<void> {
|
||||
let domains: string[] = [];
|
||||
|
||||
// Prevent concurrent updates
|
||||
if (this._isUpdating) {
|
||||
this._logService.warning(
|
||||
"[PhishingDetectionService] Update already in progress, skipping...",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._logService.info("[PhishingDetectionService] Starting phishing domains update...");
|
||||
this._isUpdating = true;
|
||||
domains = await this._auditService.getKnownPhishingDomains();
|
||||
this._setKnownPhishingDomains(domains);
|
||||
|
||||
await this._saveDomains();
|
||||
|
||||
this._resetRetry();
|
||||
this._isUpdating = false;
|
||||
|
||||
this._logService.info("[PhishingDetectionService] Successfully fetched domains");
|
||||
} catch (error) {
|
||||
this._logService.error(
|
||||
"[PhishingDetectionService] Failed to fetch known phishing domains.",
|
||||
error,
|
||||
);
|
||||
|
||||
this._scheduleRetry();
|
||||
this._isUpdating = false;
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the known phishing domains to storage
|
||||
* Caches the updated domains and updates the last update time
|
||||
*/
|
||||
private static async _saveDomains() {
|
||||
try {
|
||||
// Cache the updated domains
|
||||
await this._storageService.save(this._STORAGE_KEY, {
|
||||
domains: Array.from(this._knownPhishingDomains),
|
||||
timestamp: this._lastUpdateTime,
|
||||
});
|
||||
this._logService.info(
|
||||
`[PhishingDetectionService] Updated phishing domains cache with ${this._knownPhishingDomains.size} domains`,
|
||||
);
|
||||
} catch (error) {
|
||||
this._logService.error(
|
||||
"[PhishingDetectionService] Failed to save known phishing domains.",
|
||||
error,
|
||||
);
|
||||
this._scheduleRetry();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the retry count and clears the retry subscription
|
||||
*/
|
||||
private static _resetRetry(): void {
|
||||
this._logService.info(
|
||||
`[PhishingDetectionService] Resetting retry count and clearing retry subscription.`,
|
||||
);
|
||||
// Reset retry count and clear retry subscription on success
|
||||
this._retryCount = 0;
|
||||
if (this._retrySubscription) {
|
||||
this._retrySubscription.unsubscribe();
|
||||
this._retrySubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds phishing domains to the known phishing domains set
|
||||
* Clears old domains to prevent memory leaks
|
||||
*
|
||||
* @param domains Array of phishing domains to add
|
||||
*/
|
||||
private static _setKnownPhishingDomains(domains: string[]): void {
|
||||
this._logService.debug(
|
||||
`[PhishingDetectionService] Tracking ${domains.length} phishing domains`,
|
||||
);
|
||||
|
||||
// Clear old domains to prevent memory leaks
|
||||
this._knownPhishingDomains.clear();
|
||||
|
||||
domains.forEach((domain: string) => {
|
||||
if (domain) {
|
||||
this._knownPhishingDomains.add(domain);
|
||||
}
|
||||
});
|
||||
this._lastUpdateTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the phishing detection service
|
||||
* Unsubscribes from all subscriptions and clears caches
|
||||
*/
|
||||
private static _cleanup() {
|
||||
if (this._updateCacheSubscription) {
|
||||
this._updateCacheSubscription.unsubscribe();
|
||||
this._updateCacheSubscription = null;
|
||||
}
|
||||
if (this._retrySubscription) {
|
||||
this._retrySubscription.unsubscribe();
|
||||
this._retrySubscription = null;
|
||||
}
|
||||
if (this._navigationEvents) {
|
||||
this._navigationEvents.unsubscribe();
|
||||
this._navigationEvents = null;
|
||||
}
|
||||
this._knownPhishingDomains.clear();
|
||||
this._destroy$.next();
|
||||
this._destroy$.complete();
|
||||
this._destroy$ = new Subject<void>();
|
||||
|
||||
this._caughtTabs.clear();
|
||||
this._lastUpdateTime = 0;
|
||||
this._isUpdating = false;
|
||||
this._isInitialized = false;
|
||||
this._retryCount = 0;
|
||||
|
||||
// Manually type cast to satisfy the listener signature due to the mixture
|
||||
// of static and instance methods in this class. To be fixed when refactoring
|
||||
|
||||
@@ -14,10 +14,4 @@ export abstract class AuditService {
|
||||
* @returns A promise that resolves to an array of BreachAccountResponse objects.
|
||||
*/
|
||||
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
|
||||
/**
|
||||
* Checks if a domain is known for phishing.
|
||||
* @param domain The domain to check.
|
||||
* @returns A promise that resolves to a boolean indicating if the domain is known for phishing.
|
||||
*/
|
||||
abstract getKnownPhishingDomains: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -80,9 +80,4 @@ export class AuditService implements AuditServiceAbstraction {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
async getKnownPhishingDomains(): Promise<string[]> {
|
||||
const response = await this.apiService.send("GET", "/phishing-domains", null, true, true);
|
||||
return response as string[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,10 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne
|
||||
web: "disk-local",
|
||||
});
|
||||
|
||||
// DIRT
|
||||
|
||||
export const PHISHING_DETECTION_DISK = new StateDefinition("phishingDetection", "disk");
|
||||
|
||||
// Platform
|
||||
|
||||
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
||||
|
||||
Reference in New Issue
Block a user