1
0
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:
Will Martin
2025-11-03 09:49:33 -05:00
committed by William Martin
parent b4420d770e
commit a1580f8aea
8 changed files with 422 additions and 367 deletions

View File

@@ -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);

View File

@@ -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");
});
});
});

View File

@@ -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 [];
}
}

View File

@@ -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
});

View File

@@ -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

View File

@@ -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[]>;
}

View File

@@ -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[];
}
}

View File

@@ -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", {