Skip to content

Feat/scan url cache #5625

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4512b2c
feat(phishing-controller): implement URL scan caching mechanism
AugmentedMode Apr 10, 2025
4b8a26e
feat(phishing-controller): implement URL scan caching mechanism
AugmentedMode Apr 10, 2025
fdf4caa
feat(phishing-controller): implement URL scan caching mechanism
AugmentedMode Apr 10, 2025
45ec7d3
feat(phishing-controller): implement URL scan caching mechanism
AugmentedMode Apr 10, 2025
e1c7792
feat(phishing-controller): implement URL scan caching mechanism
AugmentedMode Apr 10, 2025
031bff5
feat(phishing-controller): implement URL scan caching mechanism
AugmentedMode Apr 10, 2025
64f4ce4
feat(phishing-controller): implement URL scan caching mechanism
AugmentedMode Apr 10, 2025
9394718
feat(phishing-controller): implement URL scan caching mechanism
AugmentedMode Apr 10, 2025
c2e3e9a
fix(eslint): adjust jsdoc/check-tag-names warning threshold for Phish…
AugmentedMode Apr 10, 2025
e11a0d8
feat(phishing-controller): add comprehensive tests for URL scan cachi…
AugmentedMode Apr 10, 2025
af3b01a
feat(phishing-controller): add comprehensive tests for URL scan cachi…
AugmentedMode Apr 10, 2025
1b337e7
feat(phishing-controller): add comprehensive tests for URL scan cachi…
AugmentedMode Apr 10, 2025
2eacaa5
chore(eslint): remove unused warning threshold for PhishingController…
AugmentedMode Apr 10, 2025
fda7965
feat(phishing-controller): refactor URL scan caching implementation a…
AugmentedMode Apr 22, 2025
2505a59
chore(phishing-controller): update CHANGELOG with URL scan cache func…
AugmentedMode Apr 22, 2025
8d96163
chore(phishing-controller): update CHANGELOG with URL scan cache func…
AugmentedMode Apr 22, 2025
cbd9b76
Merge branch 'main' into feat/scan-url-cache
AugmentedMode Apr 22, 2025
3cd468e
fix: changelog
AugmentedMode Apr 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions eslint-warning-thresholds.json
Original file line number Diff line number Diff line change
Expand Up @@ -416,11 +416,8 @@
"packages/permission-log-controller/tests/PermissionLogController.test.ts": {
"import-x/order": 1
},
"packages/phishing-controller/src/PhishingController.test.ts": {
"import-x/no-named-as-default-member": 1
},
"packages/phishing-controller/src/PhishingController.ts": {
"jsdoc/check-tag-names": 42,
"jsdoc/check-tag-names": 38,
"jsdoc/tag-lines": 1
},
"packages/phishing-controller/src/PhishingDetector.ts": {
Expand Down
8 changes: 8 additions & 0 deletions packages/phishing-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add URL scan cache functionality to improve performance ([#5625](https://github.com/MetaMask/core/pull/5625))
- Added `UrlScanCache` class for caching phishing detection scan results
- Added methods to `PhishingController`: `setUrlScanCacheTTL`, `setUrlScanCacheMaxSize`, `clearUrlScanCache`
- Added URL scan cache state to `PhishingControllerState`
- Added configuration options: `urlScanCacheTTL` and `urlScanCacheMaxSize`

### Changed

- Bump `@metamask/controller-utils` to `^11.7.0` ([#5583](https://github.com/MetaMask/core/pull/5583))
Expand Down
325 changes: 323 additions & 2 deletions packages/phishing-controller/src/PhishingController.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Messenger } from '@metamask/base-controller';
import { strict as assert } from 'assert';
import nock from 'nock';
import nock, { cleanAll, isDone, pendingMocks } from 'nock';
import sinon from 'sinon';

import {
Expand Down Expand Up @@ -54,6 +54,7 @@ function getPhishingController(options?: Partial<PhishingControllerOptions>) {
describe('PhishingController', () => {
afterEach(() => {
sinon.restore();
cleanAll();
});

it('should have no default phishing lists', () => {
Expand Down Expand Up @@ -2154,7 +2155,7 @@ describe('PhishingController', () => {

describe('PhishingController - isBlockedRequest', () => {
afterEach(() => {
nock.cleanAll();
cleanAll();
});

it('should return false if c2DomainBlocklist is not defined or empty', async () => {
Expand Down Expand Up @@ -2588,3 +2589,323 @@ describe('PhishingController', () => {
});
});
});

describe('URL Scan Cache', () => {
let clock: sinon.SinonFakeTimers;

beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
sinon.restore();
cleanAll();
});

it('should cache scan results and return them on subsequent calls', async () => {
const testDomain = 'example.com';

// Spy on the fetch function to track calls
const fetchSpy = jest.spyOn(global, 'fetch');

nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
});

const controller = getPhishingController();

const result1 = await controller.scanUrl(`https://${testDomain}`);
expect(result1).toStrictEqual({
domainName: testDomain,
recommendedAction: RecommendedAction.None,
});

const result2 = await controller.scanUrl(`https://${testDomain}`);
expect(result2).toStrictEqual({
domainName: testDomain,
recommendedAction: RecommendedAction.None,
});

// Verify that fetch was called exactly once
expect(fetchSpy).toHaveBeenCalledTimes(1);

fetchSpy.mockRestore();
});

it('should expire cache entries after TTL', async () => {
const testDomain = 'example.com';
const cacheTTL = 300; // 5 minutes

nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
})
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
});

const controller = getPhishingController({
urlScanCacheTTL: cacheTTL,
});

await controller.scanUrl(`https://${testDomain}`);

// Before TTL expires, should use cache
clock.tick((cacheTTL - 10) * 1000);
await controller.scanUrl(`https://${testDomain}`);
expect(pendingMocks()).toHaveLength(1); // One mock remaining

// After TTL expires, should fetch again
clock.tick(11 * 1000);
await controller.scanUrl(`https://${testDomain}`);
expect(pendingMocks()).toHaveLength(0); // All mocks used
});

it('should evict oldest entries when cache exceeds max size', async () => {
const maxCacheSize = 2;
const domains = ['domain1.com', 'domain2.com', 'domain3.com'];

// Setup nock to respond to all three domains
domains.forEach((domain) => {
nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
});
});

// Setup a second request for the first domain
nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domains[0])}`,
)
.reply(200, {
recommendedAction: RecommendedAction.Warn,
});

const controller = getPhishingController({
urlScanCacheMaxSize: maxCacheSize,
});

// Fill the cache
await controller.scanUrl(`https://${domains[0]}`);
clock.tick(1000); // Ensure different timestamps
await controller.scanUrl(`https://${domains[1]}`);

// This should evict the oldest entry (domain1)
clock.tick(1000);
await controller.scanUrl(`https://${domains[2]}`);

// Now domain1 should not be in cache and require a new fetch
await controller.scanUrl(`https://${domains[0]}`);

// All mocks should be used
expect(isDone()).toBe(true);
});

it('should clear the cache when clearUrlScanCache is called', async () => {
const testDomain = 'example.com';

nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
})
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
});

const controller = getPhishingController();

// First call should fetch from API
await controller.scanUrl(`https://${testDomain}`);

// Clear the cache
controller.clearUrlScanCache();

// Should fetch again
await controller.scanUrl(`https://${testDomain}`);

// All mocks should be used
expect(isDone()).toBe(true);
});

it('should allow changing the TTL', async () => {
const testDomain = 'example.com';
const initialTTL = 300; // 5 minutes
const newTTL = 60; // 1 minute

nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
})
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
});

const controller = getPhishingController({
urlScanCacheTTL: initialTTL,
});

// First call should fetch from API
await controller.scanUrl(`https://${testDomain}`);

// Change TTL
controller.setUrlScanCacheTTL(newTTL);

// Before new TTL expires, should use cache
clock.tick((newTTL - 10) * 1000);
await controller.scanUrl(`https://${testDomain}`);
expect(pendingMocks()).toHaveLength(1); // One mock remaining

// After new TTL expires, should fetch again
clock.tick(11 * 1000);
await controller.scanUrl(`https://${testDomain}`);
expect(pendingMocks()).toHaveLength(0); // All mocks used
});

it('should allow changing the max cache size', async () => {
const initialMaxSize = 3;
const newMaxSize = 2;
const domains = [
'domain1.com',
'domain2.com',
'domain3.com',
'domain4.com',
];

// Setup nock to respond to all domains
domains.forEach((domain) => {
nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
});
});

const controller = getPhishingController({
urlScanCacheMaxSize: initialMaxSize,
});

// Fill the cache to initial size
await controller.scanUrl(`https://${domains[0]}`);
clock.tick(1000); // Ensure different timestamps
await controller.scanUrl(`https://${domains[1]}`);
clock.tick(1000);
await controller.scanUrl(`https://${domains[2]}`);

// Verify initial cache size
expect(Object.keys(controller.state.urlScanCache)).toHaveLength(
initialMaxSize,
);
// Reduce the max size
controller.setUrlScanCacheMaxSize(newMaxSize);

// Add another entry which should trigger eviction
await controller.scanUrl(`https://${domains[3]}`);

// Verify the cache size doesn't exceed new max size
expect(
Object.keys(controller.state.urlScanCache).length,
).toBeLessThanOrEqual(newMaxSize);
});

it('should handle fetch errors and not cache them', async () => {
const testDomain = 'example.com';

nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(500, { error: 'Internal Server Error' })
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
});

const controller = getPhishingController();

// First call should result in an error response
const result1 = await controller.scanUrl(`https://${testDomain}`);
expect(result1.fetchError).toBeDefined();

// Second call should try again (not use cache since errors aren't cached)
const result2 = await controller.scanUrl(`https://${testDomain}`);
expect(result2.fetchError).toBeUndefined();
expect(result2.recommendedAction).toBe(RecommendedAction.None);

// All mocks should be used
expect(isDone()).toBe(true);
});

it('should handle timeout errors and not cache them', async () => {
const testDomain = 'example.com';

// First mock a timeout/error response
nock(PHISHING_DETECTION_BASE_URL)
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.replyWithError('connection timeout')
.get(
`/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`,
)
.reply(200, {
recommendedAction: RecommendedAction.None,
});

const controller = getPhishingController();

// First call should result in an error
const result1 = await controller.scanUrl(`https://${testDomain}`);
expect(result1.fetchError).toBeDefined();

// Second call should succeed (not use cache since errors aren't cached)
const result2 = await controller.scanUrl(`https://${testDomain}`);
expect(result2.fetchError).toBeUndefined();
expect(result2.recommendedAction).toBe(RecommendedAction.None);

// All mocks should be used
expect(isDone()).toBe(true);
});

it('should handle invalid URLs and not cache them', async () => {
const invalidUrl = 'not-a-valid-url';

const controller = getPhishingController();

// First call should return an error for invalid URL
const result1 = await controller.scanUrl(invalidUrl);
expect(result1.fetchError).toBeDefined();

// Second call should also return an error (not from cache)
const result2 = await controller.scanUrl(invalidUrl);
expect(result2.fetchError).toBeDefined();
});
});
Loading
Loading