diff --git a/packages/api/src/lib/phishing-detector.ts b/packages/api/src/lib/phishing-detector.ts new file mode 100644 index 0000000..5722863 --- /dev/null +++ b/packages/api/src/lib/phishing-detector.ts @@ -0,0 +1,209 @@ +export enum UrlVerdict { + SAFE = 'safe', + SUSPICIOUS = 'suspicious', + PHISHING = 'phishing', + SPAM = 'spam', + EXPOSED_CREDENTIALS = 'exposed_credentials', + UNKNOWN = 'unknown', +} + +export enum ThreatType { + PHISHING_KNOWN = 'phishing_known', + PHISHING_HEURISTIC = 'phishing_heuristic', + DOMAIN_AGE = 'domain_age', + SSL_ANOMALY = 'ssl_anomaly', + URL_ENTROPY = 'url_entropy', + TYPOSQUAT = 'typosquat', + CREDENTIAL_EXPOSURE = 'credential_exposure', + SPAM_SOURCE = 'spam_source', + REDIRECT_CHAIN = 'redirect_chain', + MIXED_CONTENT = 'mixed_content', +} + +export interface ThreatInfo { + type: ThreatType; + severity: number; + source: string; + description: string; +} + +export class PhishingDetector { + private knownSuspiciousTlds = new Set([ + '.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.click', '.link', '.work', + ]); + + private commonBrands = new Map([ + ['google', ['gmail', 'drive', 'docs', 'maps', 'play', 'chrome', 'youtube']], + ['apple', ['icloud', 'appstore', 'icloud_content', 'appleid']], + ['amazon', ['aws', 'amazonaws', 'amazon-adsystem', 'prime-video']], + ['microsoft', ['office', 'outlook', 'onedrive', 'teams', 'azure', 'windows']], + ['facebook', ['fb', 'fbcdn', 'instagram', 'whatsapp', 'messenger']], + ['paypal', ['paypalobjects', 'paypal-web', 'xoom']], + ['netflix', ['nflximg', 'nflxso', 'nflxvideo', 'nflxext']], + ]); + + analyzeUrl(url: string): { verdict: UrlVerdict; threats: ThreatInfo[]; score: number } { + const threats: ThreatInfo[] = []; + let score = 0; + + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + const domainParts = hostname.split('.'); + const tld = domainParts[domainParts.length - 1]; + + score += this.checkTld(tld, threats); + score += this.checkEntropy(parsed.pathname + parsed.search, threats); + score += this.checkTyposquatting(hostname, threats); + score += this.checkIpAddress(hostname, threats); + score += this.checkLongUrl(url, threats); + score += this.checkSubdomainDepth(domainParts, threats); + score += this.checkHttpsProtocol(parsed.protocol, threats); + score += this.checkRedirectPatterns(parsed.search, threats); + score += this.checkEncodedChars(url, threats); + score += this.checkBrandImpersonation(hostname, threats); + } catch { + return { + verdict: UrlVerdict.UNKNOWN, + threats: [{ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: 'Malformed URL' }], + score: 30, + }; + } + + const verdict = score >= 70 ? UrlVerdict.PHISHING + : score >= 40 ? UrlVerdict.SUSPICIOUS + : score >= 20 ? UrlVerdict.SPAM + : UrlVerdict.SAFE; + + return { verdict, threats, score }; + } + + private checkTld(tld: string, threats: ThreatInfo[]): number { + if (this.knownSuspiciousTlds.has(`.${tld}`)) { + threats.push({ type: ThreatType.DOMAIN_AGE, severity: 4, source: 'heuristic', description: `Suspicious TLD: .${tld}` }); + return 25; + } + return 0; + } + + private checkEntropy(pathname: string, threats: ThreatInfo[]): number { + if (!pathname || pathname.length < 20) return 0; + const entropy = this.calculateEntropy(pathname); + if (entropy > 4.5) { + threats.push({ type: ThreatType.URL_ENTROPY, severity: 4, source: 'heuristic', description: `High URL path entropy (${entropy.toFixed(2)})` }); + return 20; + } + return 0; + } + + private checkTyposquatting(hostname: string, threats: ThreatInfo[]): number { + for (const [brand, subdomains] of this.commonBrands) { + const parts = hostname.split('.'); + const main = parts[0]; + if (main.includes(brand) && main !== brand) { + const dist = this.levenshteinDistance(main, brand); + if (dist <= 2 && dist > 0) { + threats.push({ type: ThreatType.TYPOSQUAT, severity: 5, source: 'heuristic', description: `Possible typosquat of "${brand}"` }); + return 35; + } + } + const dist = this.levenshteinDistance(main, brand); + if (dist <= 2 && dist > 0 && main.length >= brand.length - 1) { + threats.push({ type: ThreatType.TYPOSQUAT, severity: 5, source: 'heuristic', description: `Possible typosquat of "${brand}"` }); + return 35; + } + for (const sub of subdomains) { + if (hostname.includes(sub) && !hostname.startsWith(`${sub}.`)) { + threats.push({ type: ThreatType.TYPOSQUAT, severity: 3, source: 'heuristic', description: `Contains "${sub}" but not official ${brand}` }); + return 15; + } + } + } + return 0; + } + + private checkIpAddress(hostname: string, threats: ThreatInfo[]): number { + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) && hostname !== '127.0.0.1') { + threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 4, source: 'heuristic', description: `IP address hostname: ${hostname}` }); + return 25; + } + return 0; + } + + private checkLongUrl(url: string, threats: ThreatInfo[]): number { + if (url.length > 200) { + threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: `Long URL (${url.length} chars)` }); + return 15; + } + return 0; + } + + private checkSubdomainDepth(parts: string[], threats: ThreatInfo[]): number { + if (parts.length > 5) { + threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: `Deep subdomains (${parts.length} levels)` }); + return 15; + } + return 0; + } + + private checkHttpsProtocol(protocol: string, threats: ThreatInfo[]): number { + if (protocol === 'http:') { + threats.push({ type: ThreatType.MIXED_CONTENT, severity: 2, source: 'heuristic', description: 'HTTP (not HTTPS)' }); + return 10; + } + return 0; + } + + private checkRedirectPatterns(query: string, threats: ThreatInfo[]): number { + const params = ['redirect', 'url', 'dest', 'return', 'next', 'target']; + const count = params.filter((p) => query.includes(`${p}=`)).length; + if (count >= 2) { + threats.push({ type: ThreatType.REDIRECT_CHAIN, severity: 3, source: 'heuristic', description: `Multiple redirect params (${count})` }); + return 15; + } + return 0; + } + + private checkEncodedChars(url: string, threats: ThreatInfo[]): number { + if (/(%[0-9a-fA-F]{2}){3,}/.test(url)) { + threats.push({ type: ThreatType.URL_ENTROPY, severity: 3, source: 'heuristic', description: 'Excessive URL encoding' }); + return 15; + } + return 0; + } + + private checkBrandImpersonation(hostname: string, threats: ThreatInfo[]): number { + const patterns = [/login[-_]?(secure|portal|page|form)/i, /account[-_]?(verify|confirm|update)/i, /secure[-_]?(signin|auth|login)/i]; + for (const pattern of patterns) { + if (pattern.test(hostname)) { + threats.push({ type: ThreatType.PHISHING_HEURISTIC, severity: 4, source: 'heuristic', description: `Phishing pattern: ${hostname}` }); + return 20; + } + } + return 0; + } + + private calculateEntropy(str: string): number { + const freq: Record = {}; + for (const c of str) freq[c] = (freq[c] || 0) + 1; + let entropy = 0; + const len = str.length; + for (const count of Object.values(freq)) { + const p = count / len; + entropy -= p * Math.log2(p); + } + return entropy; + } + + private levenshteinDistance(a: string, b: string): number { + const m: number[][] = []; + for (let i = 0; i <= b.length; i++) m[i] = [i]; + for (let j = 0; j <= a.length; j++) m[0][j] = j; + for (let i = 1; i <= b.length; i++) + for (let j = 1; j <= a.length; j++) + m[i][j] = b[i-1] === a[j-1] ? m[i-1][j-1] : Math.min(m[i-1][j-1]+1, m[i][j-1]+1, m[i-1][j]+1); + return m[b.length][a.length]; + } +} + +export const phishingDetector = new PhishingDetector(); diff --git a/packages/api/src/middleware/auth.middleware.ts b/packages/api/src/middleware/auth.middleware.ts index c14e058..738193b 100644 --- a/packages/api/src/middleware/auth.middleware.ts +++ b/packages/api/src/middleware/auth.middleware.ts @@ -16,7 +16,7 @@ export async function authMiddleware(fastify: FastifyInstance) { fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthRequest; // Skip auth for health checks and root - const publicRoutes = ['/', '/health']; + const publicRoutes = ['/', '/health', '/extension/auth']; if (publicRoutes.some((route) => request.url.startsWith(route))) { authReq.authType = 'anonymous'; return; diff --git a/packages/api/src/routes/extension.routes.ts b/packages/api/src/routes/extension.routes.ts new file mode 100644 index 0000000..14b1cd4 --- /dev/null +++ b/packages/api/src/routes/extension.routes.ts @@ -0,0 +1,208 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { phishingDetector } from './lib/phishing-detector'; + +interface UrlCheckRequest { + url: string; +} + +interface PhishingReportRequest { + url: string; + pageTitle: string; + tabId: number; + timestamp: number; + reason: string; + heuristics: Record; +} + +export async function extensionRoutes(fastify: FastifyInstance) { + fastify.post('/url-check', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string; tier?: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'Authentication required' }); + } + + const body = request.body as UrlCheckRequest; + if (!body.url) { + return reply.code(400).send({ error: 'url is required' }); + } + + try { + const url = new URL(body.url); + const heuristic = phishingDetector.analyzeUrl(body.url); + + const threats = heuristic.threats.map((t) => ({ + type: t.type, + severity: t.severity, + source: t.source, + description: t.description, + })); + + return reply.send({ + url: body.url, + domain: url.hostname, + verdict: heuristic.verdict, + confidence: heuristic.score / 100, + threats, + timestamp: Date.now(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'URL check failed'; + return reply.code(500).send({ error: message }); + } + }); + + fastify.post('/phishing-report', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'Authentication required' }); + } + + const body = request.body as PhishingReportRequest; + + try { + fastify.log.info({ url: body.url, userId, reason: body.reason }, 'Phishing report received'); + + return reply.send({ + success: true, + reportId: `report_${Date.now()}_${userId}`, + timestamp: new Date().toISOString(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Report submission failed'; + return reply.code(500).send({ error: message }); + } + }); + + fastify.post('/auth', async (request: FastifyRequest, reply: FastifyReply) => { + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return reply.code(401).send({ error: 'Bearer token required' }); + } + + const token = authHeader.slice(7); + + try { + const result = await validateExtensionToken(token, fastify); + return reply.send(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'Authentication failed'; + return reply.code(401).send({ error: message }); + } + }); + + fastify.get('/stats', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'Authentication required' }); + } + + try { + const today = new Date().toDateString(); + return reply.send({ + threatsBlockedToday: 0, + urlsCheckedToday: 0, + lastSyncAt: new Date().toISOString(), + syncDate: today, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Stats retrieval failed'; + return reply.code(500).send({ error: message }); + } + }); + + fastify.post('/exposures/check', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as FastifyRequest & { user?: { id: string } }; + const userId = authReq.user?.id; + + if (!userId) { + return reply.code(401).send({ error: 'Authentication required' }); + } + + const body = request.body as { domain: string }; + if (!body.domain) { + return reply.code(400).send({ error: 'domain is required' }); + } + + try { + const { prisma } = await import('@shieldai/db'); + + const exposures = await prisma.exposure.findMany({ + where: { + alert: { + some: { + userId, + }, + }, + }, + select: { + dataSource: true, + breachName: true, + metadata: true, + }, + take: 10, + }); + + const domainLower = body.domain.toLowerCase(); + const relevantExposures = exposures.filter((e) => { + const meta = e.metadata as Record | null; + return meta?.domain?.toLowerCase() === domainLower || + String(e.breachName).toLowerCase().includes(domainLower); + }); + + return reply.send({ + exposed: relevantExposures.length > 0, + sources: relevantExposures.map((e) => e.dataSource), + count: relevantExposures.length, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Exposure check failed'; + return reply.code(500).send({ error: message }); + } + }); +} + +async function validateExtensionToken( + token: string, + fastify: FastifyInstance +): Promise<{ userId: string; tier: string }> { + try { + const { prisma } = await import('@shieldai/db'); + + const session = await prisma.session.findFirst({ + where: { token }, + include: { + user: { + include: { + subscription: { + where: { status: 'active' }, + take: 1, + }, + }, + }, + }, + }); + + if (!session) { + throw new Error('Session not found'); + } + + const tier = session.user.subscription[0]?.tier || 'basic'; + + return { + userId: session.userId, + tier: tier.toLowerCase(), + }; + } catch (error) { + if (error instanceof Error && error.message === 'Session not found') { + throw error; + } + fastify.log.warn({ error }, 'Extension token validation failed'); + throw new Error('Token validation failed'); + } +} diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 57b4182..142f0ce 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -7,6 +7,7 @@ import { authMiddleware } from "./middleware/auth.middleware"; import { darkwatchRoutes } from "./routes/darkwatch.routes"; import { voiceprintRoutes } from "./routes/voiceprint.routes"; import { correlationRoutes } from "./routes/correlation.routes"; +import { extensionRoutes } from "./routes/extension.routes"; import { initDatadog, initSentry, captureSentryError } from "@shieldai/monitoring"; import { getCorsOrigins } from "./config/api.config"; @@ -40,6 +41,7 @@ async function bootstrap() { await app.register(darkwatchRoutes); await app.register(voiceprintRoutes); await app.register(correlationRoutes); + await app.register(extensionRoutes, { prefix: '/extension' }); app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() })); diff --git a/packages/extension/package.json b/packages/extension/package.json new file mode 100644 index 0000000..27affeb --- /dev/null +++ b/packages/extension/package.json @@ -0,0 +1,23 @@ +{ + "name": "@shieldai/extension", + "version": "0.1.0", + "private": true, + "description": "ShieldAI Browser Extension - Phishing & Spam Protection", + "scripts": { + "build": "vite build", + "build:chrome": "vite build --mode chrome", + "build:firefox": "vite build --mode firefox", + "dev": "vite build --watch --mode chrome", + "test": "vitest run", + "lint": "eslint src/" + }, + "dependencies": { + "@shieldai/types": "workspace:*" + }, + "devDependencies": { + "@types/chrome": "^0.0.268", + "vite": "^5.4.0", + "typescript": "^5.7.0", + "vitest": "^4.1.5" + } +} diff --git a/packages/extension/public/icons/icon128.png b/packages/extension/public/icons/icon128.png new file mode 100644 index 0000000..7b6cd87 Binary files /dev/null and b/packages/extension/public/icons/icon128.png differ diff --git a/packages/extension/public/icons/icon128.svg b/packages/extension/public/icons/icon128.svg new file mode 100644 index 0000000..7354f64 --- /dev/null +++ b/packages/extension/public/icons/icon128.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/extension/public/icons/icon16.png b/packages/extension/public/icons/icon16.png new file mode 100644 index 0000000..1534b6b Binary files /dev/null and b/packages/extension/public/icons/icon16.png differ diff --git a/packages/extension/public/icons/icon48.png b/packages/extension/public/icons/icon48.png new file mode 100644 index 0000000..185f80a Binary files /dev/null and b/packages/extension/public/icons/icon48.png differ diff --git a/packages/extension/public/manifest.json b/packages/extension/public/manifest.json new file mode 100644 index 0000000..9bf9d6a --- /dev/null +++ b/packages/extension/public/manifest.json @@ -0,0 +1,48 @@ +{ + "manifest_version": 3, + "name": "ShieldAI - Phishing & Spam Protection", + "version": "0.1.0", + "description": "Real-time phishing detection and spam protection powered by ShieldAI", + "permissions": [ + "activeTab", + "storage", + "tabs", + "scripting", + "declarativeNetRequest" + ], + "host_permissions": [ + "https://*/*", + "http://*/*" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "options_page": "options.html", + "content_scripts": [ + { + "matches": ["https://*/*", "http://*/*"], + "js": ["content.js"], + "run_at": "document_start", + "all_frames": false + } + ], + "declarative_net_request": { + "rule_resources": [ + { + "id": "phishing_rules", + "enabled": true, + "path": "rules/phishing-rules.json" + } + ] + } +} diff --git a/packages/extension/public/rules/phishing-rules.json b/packages/extension/public/rules/phishing-rules.json new file mode 100644 index 0000000..678cc2c --- /dev/null +++ b/packages/extension/public/rules/phishing-rules.json @@ -0,0 +1,58 @@ +[ + { + "id": 1, + "priority": 1, + "action": { "type": "BLOCK" }, + "condition": { + "urlFilter": "*://*login-secure-portal*/*", + "resourceTypes": ["main_frame"] + } + }, + { + "id": 2, + "priority": 1, + "action": { "type": "BLOCK" }, + "condition": { + "urlFilter": "*://*account-verify-now*/*", + "resourceTypes": ["main_frame"] + } + }, + { + "id": 3, + "priority": 1, + "action": { "type": "BLOCK" }, + "condition": { + "urlFilter": "*://*secure-auth-signin*/*", + "resourceTypes": ["main_frame"] + } + }, + { + "id": 4, + "priority": 1, + "action": { "type": "BLOCK" }, + "condition": { + "urlFilter": "*://*wallet-connect-verify*/*", + "resourceTypes": ["main_frame"] + } + }, + { + "id": 5, + "priority": 2, + "action": { "type": "REDIRECT", "redirect": { "urlFilter": "chrome-extension://__MSG_@@extension_id__/popup.html" } }, + "condition": { + "urlFilter": "*://*.tk/*", + "resourceTypes": ["main_frame"], + "domainMatches": ["*.tk"] + } + }, + { + "id": 6, + "priority": 2, + "action": { "type": "REDIRECT", "redirect": { "urlFilter": "chrome-extension://__MSG_@@extension_id__/popup.html" } }, + "condition": { + "urlFilter": "*://*.xyz/*", + "resourceTypes": ["main_frame"], + "domainMatches": ["*.xyz"] + } + } +] diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts new file mode 100644 index 0000000..1abe98d --- /dev/null +++ b/packages/extension/src/background/index.ts @@ -0,0 +1,237 @@ +import { UrlCheckResult, UrlVerdict, ThreatInfo, BackgroundMessage, MessageType, SubscriptionTier, PhishingReport } from '../types'; +import { urlCache, CACHE_TTL } from './cache'; +import { phishingDetector } from './phishing-detector'; +import { settingsManager } from './settings'; +import { shieldApiClient } from './api-client'; + +let threatsBlockedToday = 0; +let urlsCheckedToday = 0; +let lastThreat: ThreatInfo | null = null; + +chrome.runtime.onInstalled.addListener(async () => { + await urlCache.loadFromStorage(); + await settingsManager.load(); + + const stats = await chrome.storage.local.get('dailyStats'); + if (stats.dailyStats && stats.dailyStats.date === new Date().toDateString()) { + threatsBlockedToday = stats.dailyStats.threatsBlocked; + urlsCheckedToday = stats.dailyStats.urlsChecked; + } else { + threatsBlockedToday = 0; + urlsCheckedToday = 0; + await saveDailyStats(); + } + + chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((details) => { + chrome.storage.local.get('blockedRequests').then((data) => { + const blocked = data.blockedRequests || []; + blocked.push({ ruleId: details.ruleId, url: details.requestUrl, timestamp: Date.now() }); + if (blocked.length > 100) blocked.shift(); + chrome.storage.local.set({ blockedRequests: blocked }); + }); + }); +}); + +chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.status !== 'loading' || !tab.url) return; + + const enabled = await settingsManager.isProtectionEnabled(); + if (!enabled) return; + + const url = tab.url; + if (url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return; + + await analyzeAndAct(url, tabId); +}); + +async function analyzeAndAct(url: string, tabId: number): Promise { + urlsCheckedToday++; + const startTime = Date.now(); + + const allowed = await isShieldAIUrl(url); + if (allowed) return; + + const blocked = await settingsManager.isDomainBlocked(extractDomain(url)); + if (blocked) { + threatsBlockedToday++; + await showBlockedPage(tabId, url); + await saveDailyStats(); + return; + } + + const cached = await urlCache.get(url); + if (cached) { + broadcastResult(cached, tabId); + if (cached.verdict === UrlVerdict.PHISHING) { + const features = await settingsManager.getFeatures(); + if (features.activeBlocking) { + threatsBlockedToday++; + await showBlockedPage(tabId, url); + } else { + showWarningNotification(cached); + } + } + await saveDailyStats(); + return; + } + + const heuristic = phishingDetector.analyzeUrl(url); + let result: UrlCheckResult = { + url, + domain: extractDomain(url), + verdict: heuristic.verdict, + confidence: heuristic.score / 100, + threats: heuristic.threats, + cached: false, + latencyMs: Date.now() - startTime, + timestamp: Date.now(), + }; + + const apiResult = await shieldApiClient.checkUrl(url); + if (apiResult && apiResult.threats.length > 0) { + result = apiResult; + } + + const darkWatchEnabled = await settingsManager.get(); + if (darkWatchEnabled.darkWatchEnabled) { + const exposure = await shieldApiClient.checkDomainExposure(result.domain); + if (exposure && exposure.exposed) { + result.threats.push({ + type: 'credential_exposure' as any, + severity: 4, + source: 'darkwatch', + description: `Credentials for ${result.domain} found in ${exposure.sources.length} breach(es)`, + }); + if (result.verdict === UrlVerdict.SAFE) { + result.verdict = UrlVerdict.EXPOSED_CREDENTIALS; + } + } + } + + await urlCache.set(url, result); + broadcastResult(result, tabId); + + if (result.verdict === UrlVerdict.PHISHING) { + const features = await settingsManager.getFeatures(); + if (features.activeBlocking) { + threatsBlockedToday++; + await showBlockedPage(tabId, url); + } else { + showWarningNotification(result); + } + } + + if (result.threats.length > 0) { + lastThreat = result.threats[0]; + } + + await saveDailyStats(); +} + +function broadcastResult(result: UrlCheckResult, tabId: number): void { + chrome.tabs.sendMessage(tabId, { + type: MessageType.CHECK_URL_RESPONSE, + payload: result, + }).catch(() => {}); +} + +async function showBlockedPage(tabId: number, url: string): Promise { + const blockedUrl = chrome.runtime.getURL(`popup.html?blocked=${encodeURIComponent(url)}`); + await chrome.tabs.update(tabId, { url: blockedUrl }); +} + +function showWarningNotification(result: UrlCheckResult): void { + const showNotif = settingsManager.get().then(s => s.showNotifications); + Promise.resolve(showNotif).then((enabled) => { + if (!enabled) return; + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon48.png', + title: 'ShieldAI Warning', + message: `${result.verdict.toUpperCase()}: ${result.domain}`, + priority: result.verdict === UrlVerdict.PHISHING ? 2 : 0, + }); + }); +} + +async function saveDailyStats(): Promise { + await chrome.storage.local.set({ + dailyStats: { + date: new Date().toDateString(), + threatsBlocked: threatsBlockedToday, + urlsChecked: urlsCheckedToday, + }, + }); + await urlCache.persistToStorage(); +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +async function isShieldAIUrl(url: string): Promise { + const settings = await settingsManager.get(); + const domain = extractDomain(url); + const apiDomain = new URL(settings.apiBaseUrl).hostname; + return domain === apiDomain || await settingsManager.isDomainAllowed(domain); +} + +chrome.runtime.onMessage.addListener( + (message: BackgroundMessage, sender, sendResponse) => { + handleMessage(message, sender).then((res) => { + if (res) sendResponse(res); + }); + return true; + } +); + +async function handleMessage( + message: BackgroundMessage, + sender: chrome.runtime.MessageSender +): Promise | void> { + switch (message.type) { + case MessageType.CHECK_URL: { + const url = message.payload?.url as string; + if (!url) return; + const tabId = sender.tab?.id || 0; + await analyzeAndAct(url, tabId); + break; + } + + case MessageType.GET_SETTINGS: + return { settings: await settingsManager.get() }; + + case MessageType.UPDATE_SETTINGS: + return { settings: await settingsManager.update(message.payload as Partial) }; + + case MessageType.REPORT_PHISHING: { + const report = message.payload as PhishingReport; + const success = await shieldApiClient.submitPhishingReport(report); + return { success }; + } + + case MessageType.GET_POPUP_DATA: + return { + protectionEnabled: await settingsManager.isProtectionEnabled(), + tier: await settingsManager.getTier(), + threatsBlockedToday, + urlsCheckedToday, + lastThreat, + isLoggedIn: await settingsManager.isLoggedIn(), + }; + + case MessageType.TOGGLE_PROTECTION: { + const enabled = await settingsManager.toggleProtection(); + return { enabled }; + } + + case MessageType.AUTH_LOGOUT: { + await settingsManager.update({ authToken: null, userId: null, tier: null }); + return { success: true }; + } + } +} diff --git a/packages/extension/src/content/index.ts b/packages/extension/src/content/index.ts new file mode 100644 index 0000000..6e57d87 --- /dev/null +++ b/packages/extension/src/content/index.ts @@ -0,0 +1,141 @@ +import { BackgroundMessage, MessageType, UrlCheckResult, UrlVerdict } from '../types'; + +let currentUrlVerdict: UrlVerdict | null = null; +let statusBar: HTMLElement | null = null; + +chrome.runtime.onMessage.addListener( + (message: BackgroundMessage) => { + switch (message.type) { + case MessageType.CHECK_URL_RESPONSE: { + const result = message.payload as UrlCheckResult; + currentUrlVerdict = result.verdict; + updateStatusBar(result); + injectPageBanner(result); + highlightSuspiciousLinks(result); + break; + } + } + } +); + +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.sync.get('shieldaiSettings', (data) => { + if (data.shieldaiSettings?.enabled !== false) { + requestUrlCheck(); + } + }); +}); + +function requestUrlCheck(): void { + const url = window.location.href; + if (url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return; + + chrome.runtime.sendMessage({ type: MessageType.CHECK_URL, payload: { url } }).catch(() => {}); +} + +function updateStatusBar(result: UrlCheckResult): void { + if (!statusBar) { + statusBar = document.createElement('div'); + statusBar.id = 'shieldai-status-bar'; + Object.assign(statusBar.style, { + position: 'fixed', + top: '0', + left: '0', + right: '0', + height: '3px', + zIndex: '2147483647', + transition: 'background-color 0.3s ease', + }); + document.documentElement.insertBefore(statusBar, document.documentElement.firstChild); + } + + const colors: Record = { + [UrlVerdict.SAFE]: '#22c55e', + [UrlVerdict.SUSPICIOUS]: '#f59e0b', + [UrlVerdict.PHISHING]: '#ef4444', + [UrlVerdict.SPAM]: '#f97316', + [UrlVerdict.EXPOSED_CREDENTIALS]: '#a855f7', + [UrlVerdict.UNKNOWN]: '#6b7280', + }; + + statusBar.style.backgroundColor = colors[result.verdict] || colors[UrlVerdict.UNKNOWN]; + statusBar.title = `ShieldAI: ${result.verdict} (${result.threats.length} threat${result.threats.length !== 1 ? 's' : ''})`; +} + +function injectPageBanner(result: UrlCheckResult): void { + const existing = document.getElementById('shieldai-banner'); + if (existing) existing.remove(); + + if (result.verdict === UrlVerdict.SAFE || result.verdict === UrlVerdict.UNKNOWN) return; + + const banner = document.createElement('div'); + banner.id = 'shieldai-banner'; + banner.innerHTML = ` +
+ 🛡️ + ShieldAI: ${result.verdict.toUpperCase()} — ${result.threats[0]?.description || 'Potential threat detected'} + +
+ `; + + Object.assign(banner.style, { + position: 'fixed', + top: '3px', + left: '0', + right: '0', + zIndex: '2147483646', + backgroundColor: result.verdict === UrlVerdict.PHISHING ? '#fef2f2' : '#fffbeb', + borderBottom: `2px solid ${result.verdict === UrlVerdict.PHISHING ? '#ef4444' : '#f59e0b'}`, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fontSize: '13px', + color: '#374151', + }); + + const content = banner.querySelector('#shieldai-banner-content') as HTMLElement; + Object.assign(content.style, { + maxWidth: '800px', + margin: '0 auto', + padding: '8px 16px', + }); + + document.documentElement.insertBefore(banner, document.documentElement.firstChild.nextSibling); + + banner.querySelector('#shieldai-dismiss')?.addEventListener('click', () => { + banner.remove(); + }); +} + +function highlightSuspiciousLinks(result: UrlCheckResult): void { + if (result.verdict === UrlVerdict.SAFE) return; + + const links = document.querySelectorAll('a[href]'); + links.forEach((link) => { + const href = link.getAttribute('href'); + if (!href) return; + + try { + const linkDomain = new URL(href, window.location.href).hostname; + const pageDomain = window.location.hostname; + + if (linkDomain !== pageDomain && !linkDomain.includes(pageDomain)) { + link.classList.add('shieldai-external-link'); + link.title = `ShieldAI: External link → ${linkDomain}`; + } + } catch { + // Relative or malformed URL + } + }); + + const style = document.createElement('style'); + style.id = 'shieldai-link-styles'; + style.textContent = ` + a.shieldai-external-link::after { + content: " ↗"; + opacity: 0.5; + font-size: 0.8em; + } + `; + document.head.appendChild(style); +} + +requestUrlCheck(); diff --git a/packages/extension/src/lib/api-client.ts b/packages/extension/src/lib/api-client.ts new file mode 100644 index 0000000..b694eca --- /dev/null +++ b/packages/extension/src/lib/api-client.ts @@ -0,0 +1,131 @@ +import { UrlCheckResult, UrlVerdict, ThreatInfo, PhishingReport, SubscriptionTier } from '../types'; +import { settingsManager } from './settings'; +import { API_TIMEOUT } from './cache'; + +export class ShieldApiClient { + private baseUrl: string = ''; + + async checkUrl(url: string): Promise { + const settings = await settingsManager.get(); + const token = settings.authToken; + if (!token) return null; + + const startTime = Date.now(); + try { + const response = await fetch(`${settings.apiBaseUrl}/extension/url-check`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ url }), + signal: AbortSignal.timeout(API_TIMEOUT), + }); + + if (!response.ok) return null; + + const data = await response.json(); + const latency = Date.now() - startTime; + + return { + url, + domain: new URL(url).hostname, + verdict: data.verdict || UrlVerdict.UNKNOWN, + confidence: data.confidence || 0, + threats: (data.threats || []) as ThreatInfo[], + cached: false, + latencyMs: latency, + timestamp: Date.now(), + }; + } catch { + return null; + } + } + + async checkDomainExposure(domain: string): Promise<{ exposed: boolean; sources: string[] } | null> { + const settings = await settingsManager.get(); + const token = settings.authToken; + if (!token || !settings.darkWatchEnabled) return null; + + try { + const response = await fetch(`${settings.apiBaseUrl}/darkwatch/exposures/check`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ domain }), + signal: AbortSignal.timeout(API_TIMEOUT), + }); + + if (!response.ok) return null; + return response.json(); + } catch { + return null; + } + } + + async submitPhishingReport(report: PhishingReport): Promise { + const settings = await settingsManager.get(); + const token = settings.authToken; + if (!token) return false; + + try { + const response = await fetch(`${settings.apiBaseUrl}/extension/phishing-report`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(report), + signal: AbortSignal.timeout(3000), + }); + return response.ok; + } catch { + return false; + } + } + + async authenticate(apiKey: string): Promise<{ userId: string; tier: SubscriptionTier } | null> { + try { + const response = await fetch(`${settingsManager.get().then(s => s.apiBaseUrl)}/extension/auth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) return null; + return response.json(); + } catch { + return null; + } + } + + async getProtectionStats(): Promise<{ + threatsBlockedToday: number; + urlsCheckedToday: number; + } | null> { + const settings = await settingsManager.get(); + const token = settings.authToken; + if (!token) return null; + + try { + const response = await fetch(`${settings.apiBaseUrl}/extension/stats`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + signal: AbortSignal.timeout(3000), + }); + + if (!response.ok) return null; + return response.json(); + } catch { + return null; + } + } +} + +export const shieldApiClient = new ShieldApiClient(); diff --git a/packages/extension/src/lib/cache.ts b/packages/extension/src/lib/cache.ts new file mode 100644 index 0000000..3b690a0 --- /dev/null +++ b/packages/extension/src/lib/cache.ts @@ -0,0 +1,80 @@ +import { UrlCheckResult } from '../types'; + +const CACHE_TTL_MS = 5 * 60 * 1000; +const API_TIMEOUT_MS = 500; +const MAX_CACHE_SIZE = 5000; + +export class UrlCache { + private cache: Map = new Map(); + + async get(url: string): Promise { + const normalized = this.normalizeUrl(url); + const entry = this.cache.get(normalized); + + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + this.cache.delete(normalized); + return null; + } + + return { ...entry.result, cached: true }; + } + + async set(url: string, result: UrlCheckResult): Promise { + const normalized = this.normalizeUrl(url); + + if (this.cache.size >= MAX_CACHE_SIZE) { + const firstKey = this.cache.keys().next().value; + if (firstKey) this.cache.delete(firstKey); + } + + this.cache.set(normalized, { + result, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + } + + async persistToStorage(): Promise { + const entries: Record = {}; + for (const [key, value] of this.cache.entries()) { + entries[key] = value; + } + await chrome.storage.local.set({ urlCache: entries }); + } + + async loadFromStorage(): Promise { + const data = await chrome.storage.local.get('urlCache'); + if (data.urlCache) { + const now = Date.now(); + for (const [key, entry] of Object.entries(data.urlCache)) { + if (now <= entry.expiresAt) { + this.cache.set(key, entry); + } + } + } + } + + getStats(): { size: number; max: number } { + return { size: this.cache.size, max: MAX_CACHE_SIZE }; + } + + clear(): void { + this.cache.clear(); + } + + private normalizeUrl(url: string): string { + try { + const u = new URL(url); + u.hash = ''; + u.search = ''; + return u.toString(); + } catch { + return url.toLowerCase(); + } + } +} + +export const urlCache = new UrlCache(); +export const CACHE_TTL = CACHE_TTL_MS; +export const API_TIMEOUT = API_TIMEOUT_MS; diff --git a/packages/extension/src/lib/phishing-detector.ts b/packages/extension/src/lib/phishing-detector.ts new file mode 100644 index 0000000..b16cadf --- /dev/null +++ b/packages/extension/src/lib/phishing-detector.ts @@ -0,0 +1,277 @@ +import { ThreatType, UrlVerdict, ThreatInfo } from '../types'; + +export class PhishingDetector { + private knownSuspiciousTlds: Set = new Set([ + '.tk', '.ml', '.ga', '.cf', '.gq', '.xyz', '.top', '.click', '.link', '.work', + ]); + + private commonBrands: Map = new Map([ + ['google', ['gmail', 'drive', 'docs', 'maps', 'play', 'chrome', 'youtube']], + ['apple', ['icloud', 'appstore', 'icloud_content', 'appleid']], + ['amazon', ['aws', 'amazonaws', 'amazon-adsystem', 'prime-video']], + ['microsoft', ['office', 'outlook', 'onedrive', 'teams', 'azure', 'windows']], + ['facebook', ['fb', 'fbcdn', 'instagram', 'whatsapp', 'messenger']], + ['paypal', ['paypalobjects', 'paypal-web', 'xoom']], + ['netflix', ['nflximg', 'nflxso', 'nflxvideo', 'nflxext']], + ['bank', ['chase', 'bofa', 'wellsfargo', 'citi', 'hsbc', 'barclays']], + ]); + + analyzeUrl(url: string): { verdict: UrlVerdict; threats: ThreatInfo[]; score: number } { + const threats: ThreatInfo[] = []; + let score = 0; + + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + const domainParts = hostname.split('.'); + const tld = domainParts[domainParts.length - 1]; + + score += this.checkTld(tld, domainParts, threats); + score += this.checkEntropy(parsed.pathname + parsed.search, threats); + score += this.checkTyposquatting(hostname, threats); + score += this.checkIpAddress(hostname, threats); + score += this.checkLongUrl(url, threats); + score += this.checkSubdomainDepth(domainParts, threats); + score += this.checkHttpsProtocol(parsed.protocol, threats); + score += this.checkRedirectPatterns(parsed.search, threats); + score += this.checkEncodedChars(url, threats); + score += this.checkBrandImpersonation(hostname, threats); + } catch { + return { + verdict: UrlVerdict.UNKNOWN, + threats: [{ type: ThreatType.PHISHING_HEURISTIC, severity: 3, source: 'heuristic', description: 'Malformed URL' }], + score: 30, + }; + } + + const verdict = score >= 70 ? UrlVerdict.PHISHING + : score >= 40 ? UrlVerdict.SUSPICIOUS + : score >= 20 ? UrlVerdict.SPAM + : UrlVerdict.SAFE; + + return { verdict, threats, score }; + } + + private checkTld(tld: string, parts: string[], threats: ThreatInfo[]): number { + if (this.knownSuspiciousTlds.has(`.${tld}`)) { + threats.push({ + type: ThreatType.DOMAIN_AGE, + severity: 4, + source: 'heuristic', + description: `Suspicious TLD: .${tld}`, + }); + return 25; + } + if (parts.length === 1) { + threats.push({ + type: ThreatType.DOMAIN_AGE, + severity: 3, + source: 'heuristic', + description: 'Single-label domain (possible local or new domain)', + }); + return 15; + } + return 0; + } + + private checkEntropy(pathname: string, threats: ThreatInfo[]): number { + if (!pathname || pathname.length < 20) return 0; + const entropy = this.calculateEntropy(pathname); + if (entropy > 4.5) { + threats.push({ + type: ThreatType.URL_ENTROPY, + severity: 4, + source: 'heuristic', + description: `High URL path entropy (${entropy.toFixed(2)}) suggests obfuscation`, + }); + return 20; + } + return 0; + } + + private checkTyposquatting(hostname: string, threats: ThreatInfo[]): number { + for (const [brand, subdomains] of this.commonBrands) { + const brandParts = hostname.split('.'); + const mainDomain = brandParts.slice(0, -1).join('.'); + const firstLabel = mainDomain.split('.')[0]; + + if (mainDomain.includes(brand) && mainDomain !== brand) { + const editDist = this.levenshteinDistance(firstLabel, brand); + if (editDist <= 2 && editDist > 0) { + threats.push({ + type: ThreatType.TYPOSQUAT, + severity: 5, + source: 'heuristic', + description: `Possible typosquat of "${brand}" (edit distance: ${editDist})`, + }); + return 35; + } + } + + const editDist = this.levenshteinDistance(firstLabel, brand); + if (editDist <= 2 && editDist > 0 && firstLabel.length >= brand.length - 1) { + threats.push({ + type: ThreatType.TYPOSQUAT, + severity: 5, + source: 'heuristic', + description: `Possible typosquat of "${brand}" (edit distance: ${editDist})`, + }); + return 35; + } + + for (const sub of subdomains) { + if (hostname.includes(sub) && !hostname.startsWith(`${sub}.` + brandParts[brandParts.length - 1])) { + threats.push({ + type: ThreatType.TYPOSQUAT, + severity: 3, + source: 'heuristic', + description: `Domain contains "${sub}" but not an official ${brand} subdomain`, + }); + return 15; + } + } + } + return 0; + } + + private checkIpAddress(hostname: string, threats: ThreatInfo[]): number { + const ipPattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + if (ipPattern.test(hostname) && hostname !== '127.0.0.1' && hostname !== 'localhost') { + threats.push({ + type: ThreatType.PHISHING_HEURISTIC, + severity: 4, + source: 'heuristic', + description: `IP address used as hostname: ${hostname}`, + }); + return 25; + } + return 0; + } + + private checkLongUrl(url: string, threats: ThreatInfo[]): number { + if (url.length > 200) { + threats.push({ + type: ThreatType.PHISHING_HEURISTIC, + severity: 3, + source: 'heuristic', + description: `Unusually long URL (${url.length} chars)`, + }); + return 15; + } + return 0; + } + + private checkSubdomainDepth(parts: string[], threats: ThreatInfo[]): number { + if (parts.length > 5) { + threats.push({ + type: ThreatType.PHISHING_HEURISTIC, + severity: 3, + source: 'heuristic', + description: `Deep subdomain nesting (${parts.length} levels)`, + }); + return 15; + } + return 0; + } + + private checkHttpsProtocol(protocol: string, threats: ThreatInfo[]): number { + if (protocol === 'http:') { + threats.push({ + type: ThreatType.MIXED_CONTENT, + severity: 2, + source: 'heuristic', + description: 'Page loaded over HTTP (not HTTPS)', + }); + return 10; + } + return 0; + } + + private checkRedirectPatterns(query: string, threats: ThreatInfo[]): number { + const redirectParams = ['redirect', 'url', 'dest', 'return', 'next', 'target']; + let count = 0; + for (const param of redirectParams) { + if (query.includes(`${param}=`)) count++; + } + if (count >= 2) { + threats.push({ + type: ThreatType.REDIRECT_CHAIN, + severity: 3, + source: 'heuristic', + description: `Multiple redirect parameters detected (${count})`, + }); + return 15; + } + return 0; + } + + private checkEncodedChars(url: string, threats: ThreatInfo[]): number { + const encodedPattern = /(%[0-9a-fA-F]{2}){3,}/g; + const matches = url.match(encodedPattern); + if (matches && matches.length > 0) { + threats.push({ + type: ThreatType.URL_ENTROPY, + severity: 3, + source: 'heuristic', + description: 'Excessive URL encoding detected', + }); + return 15; + } + return 0; + } + + private checkBrandImpersonation(hostname: string, threats: ThreatInfo[]): number { + const impersonationPatterns = [ + /login[-_]?(secure|portal|page|form)/i, + /account[-_]?(verify|confirm|update)/i, + /secure[-_]?(signin|auth|login)/i, + /wallet[-_]?(connect|link|verify)/i, + ]; + for (const pattern of impersonationPatterns) { + if (pattern.test(hostname)) { + threats.push({ + type: ThreatType.PHISHING_HEURISTIC, + severity: 4, + source: 'heuristic', + description: `Common phishing pattern detected: ${hostname}`, + }); + return 20; + } + } + return 0; + } + + private calculateEntropy(str: string): number { + const freq: Record = {}; + for (const char of str) { + freq[char] = (freq[char] || 0) + 1; + } + let entropy = 0; + const len = str.length; + for (const count of Object.values(freq)) { + const p = count / len; + entropy -= p * Math.log2(p); + } + return entropy; + } + + private levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = []; + for (let i = 0; i <= b.length; i++) matrix[i] = [i]; + for (let j = 0; j <= a.length; j++) matrix[0][j] = j; + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + matrix[i][j] = b[i - 1] === a[j - 1] + ? matrix[i - 1][j - 1] + : Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1, + ); + } + } + return matrix[b.length][a.length]; + } +} + +export const phishingDetector = new PhishingDetector(); diff --git a/packages/extension/src/lib/settings.ts b/packages/extension/src/lib/settings.ts new file mode 100644 index 0000000..5a97e75 --- /dev/null +++ b/packages/extension/src/lib/settings.ts @@ -0,0 +1,117 @@ +import { ExtensionSettings, SubscriptionTier, TIER_FEATURES, MessageType } from '../types'; + +const DEFAULT_SETTINGS: ExtensionSettings = { + apiKey: '', + apiBaseUrl: 'https://api.shieldai.com', + authToken: null, + userId: null, + tier: null, + enabled: true, + activeBlocking: false, + darkWatchEnabled: false, + spamProtectionEnabled: true, + showNotifications: true, + blockedDomains: [], + allowedDomains: [], + lastSyncAt: null, +}; + +export class SettingsManager { + private settings: ExtensionSettings = { ...DEFAULT_SETTINGS }; + private loaded = false; + + async load(): Promise { + if (this.loaded) return this.settings; + + const stored = await chrome.storage.sync.get('shieldaiSettings'); + if (stored.shieldaiSettings) { + this.settings = { ...DEFAULT_SETTINGS, ...stored.shieldaiSettings }; + } + this.loaded = true; + return this.settings; + } + + async get(): Promise { + if (!this.loaded) await this.load(); + return { ...this.settings }; + } + + async update(partial: Partial): Promise { + await this.load(); + this.settings = { ...this.settings, ...partial }; + await chrome.storage.sync.set({ shieldaiSettings: this.settings }); + return { ...this.settings }; + } + + async getAuthToken(): Promise { + await this.load(); + return this.settings.authToken; + } + + async isLoggedIn(): Promise { + await this.load(); + return this.settings.authToken !== null && this.settings.userId !== null; + } + + async getTier(): Promise { + await this.load(); + return this.settings.tier; + } + + async getFeatures(): Promise { + const tier = await this.getTier(); + if (tier) return TIER_FEATURES[tier]; + return TIER_FEATURES[SubscriptionTier.BASIC]; + } + + async isDomainBlocked(domain: string): Promise { + await this.load(); + return this.settings.blockedDomains.some( + (d) => d.toLowerCase() === domain.toLowerCase() + ); + } + + async isDomainAllowed(domain: string): Promise { + await this.load(); + return this.settings.allowedDomains.some( + (d) => d.toLowerCase() === domain.toLowerCase() + ); + } + + async isProtectionEnabled(): Promise { + await this.load(); + return this.settings.enabled; + } + + async toggleProtection(): Promise { + await this.load(); + this.settings.enabled = !this.settings.enabled; + await chrome.storage.sync.set({ shieldaiSettings: this.settings }); + return this.settings.enabled; + } + + async addBlockedDomain(domain: string): Promise { + await this.load(); + const lower = domain.toLowerCase(); + if (!this.settings.blockedDomains.includes(lower)) { + this.settings.blockedDomains.push(lower); + await chrome.storage.sync.set({ shieldaiSettings: this.settings }); + } + } + + async removeBlockedDomain(domain: string): Promise { + await this.load(); + this.settings.blockedDomains = this.settings.blockedDomains.filter( + (d) => d !== domain.toLowerCase() + ); + await chrome.storage.sync.set({ shieldaiSettings: this.settings }); + } + + async reset(): Promise { + this.settings = { ...DEFAULT_SETTINGS }; + this.loaded = true; + await chrome.storage.sync.set({ shieldaiSettings: this.settings }); + } +} + +export const settingsManager = new SettingsManager(); diff --git a/packages/extension/src/options/options.html b/packages/extension/src/options/options.html new file mode 100644 index 0000000..fbac82f --- /dev/null +++ b/packages/extension/src/options/options.html @@ -0,0 +1,189 @@ + + + + + + ShieldAI Options + + + +

🛡️ ShieldAI Options

+

Configure your phishing & spam protection

+ +
+
Connection
+
+ + +
+
+ + +
+
+ +
+
Protection Settings
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Blocked Domains
+
    +
    + + +
    +
    + +
    +
    Allowed Domains (Whitelist)
    +
      +
      + + +
      +
      + +
      + + +
      + +
      Settings saved!
      + + + + diff --git a/packages/extension/src/options/options.ts b/packages/extension/src/options/options.ts new file mode 100644 index 0000000..3cea5ec --- /dev/null +++ b/packages/extension/src/options/options.ts @@ -0,0 +1,113 @@ +import { BackgroundMessage, MessageType } from '../types'; + +const apiUrlInput = document.getElementById('api-url') as HTMLInputElement; +const authTokenInput = document.getElementById('auth-token') as HTMLInputElement; +const enabledCheckbox = document.getElementById('enabled') as HTMLInputElement; +const activeBlockingCheckbox = document.getElementById('active-blocking') as HTMLInputElement; +const darkwatchCheckbox = document.getElementById('darkwatch-enabled') as HTMLInputElement; +const spamCheckbox = document.getElementById('spam-enabled') as HTMLInputElement; +const notificationsCheckbox = document.getElementById('notifications') as HTMLInputElement; +const blockedDomainsList = document.getElementById('blocked-domains') as HTMLElement; +const allowedDomainsList = document.getElementById('allowed-domains') as HTMLElement; +const newBlockedInput = document.getElementById('new-blocked-domain') as HTMLInputElement; +const newAllowedInput = document.getElementById('new-allowed-domain') as HTMLInputElement; +const saveBtn = document.getElementById('save-btn') as HTMLButtonElement; +const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement; +const toast = document.getElementById('toast') as HTMLElement; + +loadSettings(); + +function loadSettings(): void { + chrome.runtime.sendMessage({ type: MessageType.GET_SETTINGS }, (response) => { + const settings = (response as { settings: Record }).settings; + if (!settings) return; + + apiUrlInput.value = settings.apiBaseUrl || 'https://api.shieldai.com'; + authTokenInput.value = settings.authToken || ''; + enabledCheckbox.checked = settings.enabled !== false; + activeBlockingCheckbox.checked = !!settings.activeBlocking; + darkwatchCheckbox.checked = !!settings.darkWatchEnabled; + spamCheckbox.checked = settings.spamProtectionEnabled !== false; + notificationsCheckbox.checked = settings.showNotifications !== false; + + renderDomainList(blockedDomainsList, (settings.blockedDomains || []) as string[], 'blocked'); + renderDomainList(allowedDomainsList, (settings.allowedDomains || []) as string[], 'allowed'); + }); +} + +function renderDomainList(container: HTMLElement, domains: string[], type: string): void { + container.innerHTML = domains.map((d, i) => ` +
    • + ${d} + +
    • + `).join(''); +} + +saveBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ + type: MessageType.UPDATE_SETTINGS, + payload: { + apiBaseUrl: apiUrlInput.value, + authToken: authTokenInput.value || null, + enabled: enabledCheckbox.checked, + activeBlocking: activeBlockingCheckbox.checked, + darkWatchEnabled: darkwatchCheckbox.checked, + spamProtectionEnabled: spamCheckbox.checked, + showNotifications: notificationsCheckbox.checked, + }, + }, () => { + showToast('Settings saved!'); + }); +}); + +resetBtn.addEventListener('click', () => { + chrome.storage.sync.set({ shieldaiSettings: null }, () => { + chrome.runtime.sendMessage({ type: MessageType.GET_SETTINGS }, (response) => { + loadSettings(); + showToast('Settings reset to defaults'); + }); + }); +}); + +document.getElementById('add-blocked')?.addEventListener('click', () => { + const domain = newBlockedInput.value.trim().toLowerCase(); + if (!domain) return; + + chrome.runtime.sendMessage({ type: MessageType.GET_SETTINGS }, (response) => { + const settings = (response as { settings: Record }).settings; + const domains = [...(settings.blockedDomains || []), domain]; + chrome.runtime.sendMessage({ + type: MessageType.UPDATE_SETTINGS, + payload: { blockedDomains: domains }, + }, () => { + newBlockedInput.value = ''; + renderDomainList(blockedDomainsList, domains, 'blocked'); + showToast(`Added ${domain} to blocked list`); + }); + }); +}); + +document.getElementById('add-allowed')?.addEventListener('click', () => { + const domain = newAllowedInput.value.trim().toLowerCase(); + if (!domain) return; + + chrome.runtime.sendMessage({ type: MessageType.GET_SETTINGS }, (response) => { + const settings = (response as { settings: Record }).settings; + const domains = [...(settings.allowedDomains || []), domain]; + chrome.runtime.sendMessage({ + type: MessageType.UPDATE_SETTINGS, + payload: { allowedDomains: domains }, + }, () => { + newAllowedInput.value = ''; + renderDomainList(allowedDomainsList, domains, 'allowed'); + showToast(`Added ${domain} to allowed list`); + }); + }); +}); + +function showToast(message: string): void { + toast.textContent = message; + toast.classList.add('show'); + setTimeout(() => toast.classList.remove('show'), 3000); +} diff --git a/packages/extension/src/popup/popup.html b/packages/extension/src/popup/popup.html new file mode 100644 index 0000000..29afc1e --- /dev/null +++ b/packages/extension/src/popup/popup.html @@ -0,0 +1,271 @@ + + + + + + ShieldAI Protection + + + + + + + + + + diff --git a/packages/extension/src/popup/popup.ts b/packages/extension/src/popup/popup.ts new file mode 100644 index 0000000..217bb58 --- /dev/null +++ b/packages/extension/src/popup/popup.ts @@ -0,0 +1,122 @@ +import { BackgroundMessage, MessageType, PopupData } from '../types'; + +const popupView = document.getElementById('popup-view') as HTMLElement; +const blockedView = document.getElementById('blocked-view') as HTMLElement; + +const protectionToggle = document.getElementById('protection-toggle') as HTMLElement; +const statusText = document.getElementById('status-text') as HTMLElement; +const accountStatus = document.getElementById('account-status') as HTMLElement; +const tierBadge = document.getElementById('tier-badge') as HTMLElement; +const threatsCount = document.getElementById('threats-count') as HTMLElement; +const urlsCount = document.getElementById('urls-count') as HTMLElement; +const lastThreat = document.getElementById('last-threat') as HTMLElement; +const threatDescription = document.getElementById('threat-description') as HTMLElement; +const blockingBadge = document.getElementById('blocking-badge') as HTMLElement; +const darkwatchBadge = document.getElementById('darkwatch-badge') as HTMLElement; +const realtimeBadge = document.getElementById('realtime-badge') as HTMLElement; + +const reportBtn = document.getElementById('report-btn') as HTMLButtonElement; +const optionsBtn = document.getElementById('options-btn') as HTMLButtonElement; +const loginBtn = document.getElementById('login-btn') as HTMLButtonElement; +const continueBtn = document.getElementById('continue-btn') as HTMLButtonElement; +const backBtn = document.getElementById('back-btn') as HTMLButtonElement; + +checkBlockedUrl(); +loadPopupData(); + +function checkBlockedUrl(): void { + const params = new URLSearchParams(window.location.search); + const blockedUrl = params.get('blocked'); + if (blockedUrl) { + popupView.classList.add('hidden'); + blockedView.classList.remove('hidden'); + document.getElementById('blocked-url')!.textContent = blockedUrl; + + continueBtn.onclick = () => { + chrome.tabs.update({ url: blockedUrl }); + }; + backBtn.onclick = () => { + chrome.tabs.goBack(); + }; + } +} + +function loadPopupData(): void { + chrome.runtime.sendMessage({ type: MessageType.GET_POPUP_DATA }, (response) => { + const data = response as PopupData; + updateUI(data); + }); +} + +function updateUI(data: PopupData): void { + statusText.textContent = data.protectionEnabled ? 'Active' : 'Paused'; + statusText.className = `status-value ${data.protectionEnabled ? 'safe' : 'warning'}`; + protectionToggle.className = `toggle ${data.protectionEnabled ? 'active' : ''}`; + + accountStatus.textContent = data.isLoggedIn ? 'Connected' : 'Guest'; + accountStatus.className = `status-value ${data.isLoggedIn ? 'safe' : ''}`; + + const tier = data.tier || 'basic'; + tierBadge.textContent = tier.charAt(0).toUpperCase() + tier.slice(1); + tierBadge.className = `tier-badge ${tier}`; + + threatsCount.textContent = data.threatsBlockedToday.toLocaleString(); + urlsCount.textContent = data.urlsCheckedToday.toLocaleString(); + + if (data.lastThreat) { + lastThreat.classList.remove('hidden'); + threatDescription.textContent = data.lastThreat.description; + } + + if (data.tier === 'plus' || data.tier === 'premium') { + blockingBadge.textContent = 'Active'; + blockingBadge.className = 'tier-badge plus'; + } + + if (data.tier === 'premium') { + darkwatchBadge.textContent = 'Active'; + darkwatchBadge.className = 'tier-badge plus'; + realtimeBadge.textContent = 'Active'; + realtimeBadge.className = 'tier-badge premium'; + } +} + +protectionToggle.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: MessageType.TOGGLE_PROTECTION }, (response) => { + const enabled = (response as { enabled: boolean }).enabled; + protectionToggle.className = `toggle ${enabled ? 'active' : ''}`; + statusText.textContent = enabled ? 'Active' : 'Paused'; + statusText.className = `status-value ${enabled ? 'safe' : 'warning'}`; + }); +}); + +reportBtn.addEventListener('click', async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.url) return; + + const title = tab.title || 'Unknown Page'; + const success = await chrome.runtime.sendMessage({ + type: MessageType.REPORT_PHISHING, + payload: { + url: tab.url, + pageTitle: title, + tabId: tab.id, + timestamp: Date.now(), + reason: 'Manual report from popup', + heuristics: {}, + }, + }); + + reportBtn.textContent = (success as { success: boolean })?.success + ? '✓ Reported' + : '⚡ Report Phishing'; + setTimeout(() => { reportBtn.innerHTML = ' Report Phishing'; }, 2000); +}); + +optionsBtn.addEventListener('click', () => { + chrome.tabs.create({ url: chrome.runtime.getURL('options.html') }); +}); + +loginBtn.addEventListener('click', () => { + chrome.tabs.create({ url: 'https://app.shieldai.com/auth/login?extension=true' }); +}); diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts new file mode 100644 index 0000000..7a3ea9c --- /dev/null +++ b/packages/extension/src/types/index.ts @@ -0,0 +1,138 @@ +export interface UrlCheckResult { + url: string; + domain: string; + verdict: UrlVerdict; + confidence: number; + threats: ThreatInfo[]; + cached: boolean; + latencyMs: number; + timestamp: number; +} + +export enum UrlVerdict { + SAFE = 'safe', + SUSPICIOUS = 'suspicious', + PHISHING = 'phishing', + SPAM = 'spam', + EXPOSED_CREDENTIALS = 'exposed_credentials', + UNKNOWN = 'unknown', +} + +export interface ThreatInfo { + type: ThreatType; + severity: number; + source: string; + description: string; +} + +export enum ThreatType { + PHISHING_KNOWN = 'phishing_known', + PHISHING_HEURISTIC = 'phishing_heuristic', + DOMAIN_AGE = 'domain_age', + SSL_ANOMALY = 'ssl_anomaly', + URL_ENTROPY = 'url_entropy', + TYPOSQUAT = 'typosquat', + CREDENTIAL_EXPOSURE = 'credential_exposure', + SPAM_SOURCE = 'spam_source', + REDIRECT_CHAIN = 'redirect_chain', + MIXED_CONTENT = 'mixed_content', +} + +export interface CachedUrlEntry { + url: string; + result: UrlCheckResult; + expiresAt: number; +} + +export interface ExtensionSettings { + apiKey: string; + apiBaseUrl: string; + authToken: string | null; + userId: string | null; + tier: SubscriptionTier | null; + enabled: boolean; + activeBlocking: boolean; + darkWatchEnabled: boolean; + spamProtectionEnabled: boolean; + showNotifications: boolean; + blockedDomains: string[]; + allowedDomains: string[]; + lastSyncAt: number | null; +} + +export enum SubscriptionTier { + BASIC = 'basic', + PLUS = 'plus', + PREMIUM = 'premium', +} + +export interface TierFeatures { + passiveWarnings: boolean; + activeBlocking: boolean; + darkWatchIntegration: boolean; + realTimeScanning: boolean; + maxDailyChecks: number; +} + +export const TIER_FEATURES: Record = { + [SubscriptionTier.BASIC]: { + passiveWarnings: true, + activeBlocking: false, + darkWatchIntegration: false, + realTimeScanning: false, + maxDailyChecks: 100, + }, + [SubscriptionTier.PLUS]: { + passiveWarnings: true, + activeBlocking: true, + darkWatchIntegration: true, + realTimeScanning: false, + maxDailyChecks: 1000, + }, + [SubscriptionTier.PREMIUM]: { + passiveWarnings: true, + activeBlocking: true, + darkWatchIntegration: true, + realTimeScanning: true, + maxDailyChecks: 10000, + }, +}; + +export interface PhishingReport { + url: string; + pageTitle: string; + tabId: number; + timestamp: number; + reason: string; + heuristics: Record; +} + +export interface PopupData { + protectionEnabled: boolean; + tier: SubscriptionTier | null; + threatsBlockedToday: number; + urlsCheckedToday: number; + lastThreat: ThreatInfo | null; + isLoggedIn: boolean; +} + +export interface BackgroundMessage { + type: MessageType; + payload?: Record; +} + +export enum MessageType { + CHECK_URL = 'check_url', + CHECK_URL_RESPONSE = 'check_url_response', + GET_SETTINGS = 'get_settings', + UPDATE_SETTINGS = 'update_settings', + REPORT_PHISHING = 'report_phishing', + AUTH_LOGIN = 'auth_login', + AUTH_LOGOUT = 'auth_logout', + GET_POPUP_DATA = 'get_popup_data', + POPUP_DATA_RESPONSE = 'popup_data_response', + DARKWATCH_CHECK = 'darkwatch_check', + DARKWATCH_RESPONSE = 'darkwatch_response', + TOGGLE_PROTECTION = 'toggle_protection', + REFRESH_TOKEN = 'refresh_token', +} diff --git a/packages/extension/tests/cache.test.ts b/packages/extension/tests/cache.test.ts new file mode 100644 index 0000000..3f2d4db --- /dev/null +++ b/packages/extension/tests/cache.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { phishingDetector } from '../src/lib/phishing-detector'; +import { UrlVerdict, ThreatType } from '../src/types'; + +describe('PhishingDetector (cache test)', () => { + + describe('analyzeUrl', () => { + it('should return SAFE for legitimate URLs', () => { + const result = phishingDetector.analyzeUrl('https://www.google.com/search?q=test'); + expect(result.verdict).toBe(UrlVerdict.SAFE); + }); + + it('should detect suspicious TLD', () => { + const result = phishingDetector.analyzeUrl('https://free-prize.tk/claim'); + expect(result.threats.some((t) => t.type === ThreatType.DOMAIN_AGE)).toBe(true); + }); + + it('should detect typosquatting', () => { + const result = phishingDetector.analyzeUrl('https://goggle.com/login'); + expect(result.threats.some((t) => t.type === ThreatType.TYPOSQUAT)).toBe(true); + }); + + it('should detect IP address hostname', () => { + const result = phishingDetector.analyzeUrl('http://192.168.1.100/admin'); + expect(result.threats.some((t) => t.type === ThreatType.PHISHING_HEURISTIC)).toBe(true); + }); + + it('should detect phishing pattern in hostname', () => { + const result = phishingDetector.analyzeUrl('https://login-secure-portal.xyz/account'); + expect(result.threats.some((t) => t.type === ThreatType.PHISHING_HEURISTIC)).toBe(true); + }); + + it('should detect HTTP protocol', () => { + const result = phishingDetector.analyzeUrl('http://example.com/login'); + expect(result.threats.some((t) => t.type === ThreatType.MIXED_CONTENT)).toBe(true); + }); + + it('should return UNKNOWN for malformed URLs', () => { + const result = phishingDetector.analyzeUrl('not-a-real-url'); + expect(result.verdict).toBe(UrlVerdict.UNKNOWN); + }); + }); +}); diff --git a/packages/extension/tests/phishing-detector.test.ts b/packages/extension/tests/phishing-detector.test.ts new file mode 100644 index 0000000..cb1d644 --- /dev/null +++ b/packages/extension/tests/phishing-detector.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { phishingDetector } from '../src/lib/phishing-detector'; +import { UrlVerdict, ThreatType } from '../src/types'; + +describe('PhishingDetector', () => { + const detector = phishingDetector; + + describe('analyzeUrl', () => { + it('should return SAFE for legitimate URLs', () => { + const result = detector.analyzeUrl('https://www.google.com/search?q=test'); + expect(result.verdict).toBe(UrlVerdict.SAFE); + expect(result.score).toBeLessThan(20); + }); + + it('should detect suspicious TLD', () => { + const result = detector.analyzeUrl('https://free-prize.tk/claim'); + expect(result.threats.some((t) => t.type === ThreatType.DOMAIN_AGE)).toBe(true); + expect(result.score).toBeGreaterThanOrEqual(25); + }); + + it('should detect typosquatting', () => { + const result = detector.analyzeUrl('https://goggle.com/login'); + expect(result.threats.some((t) => t.type === ThreatType.TYPOSQUAT)).toBe(true); + expect(result.score).toBeGreaterThanOrEqual(35); + }); + + it('should detect IP address hostname', () => { + const result = detector.analyzeUrl('http://192.168.1.100/admin'); + expect(result.threats.some((t) => t.type === ThreatType.PHISHING_HEURISTIC)).toBe(true); + expect(result.score).toBeGreaterThanOrEqual(25); + }); + + it('should detect phishing pattern in hostname', () => { + const result = detector.analyzeUrl('https://login-secure-portal.xyz/account'); + expect(result.threats.some((t) => t.type === ThreatType.PHISHING_HEURISTIC)).toBe(true); + }); + + it('should detect HTTP protocol', () => { + const result = detector.analyzeUrl('http://example.com/login'); + expect(result.threats.some((t) => t.type === ThreatType.MIXED_CONTENT)).toBe(true); + }); + + it('should detect deep subdomain nesting', () => { + const result = detector.analyzeUrl('https://a.b.c.d.e.f.example.com/login'); + expect(result.threats.some((t) => t.type === ThreatType.PHISHING_HEURISTIC)).toBe(true); + }); + + it('should detect multiple redirect parameters', () => { + const result = detector.analyzeUrl('https://example.com/page?redirect=/login&next=/dashboard&return=/home'); + expect(result.threats.some((t) => t.type === ThreatType.REDIRECT_CHAIN)).toBe(true); + }); + + it('should detect excessive URL encoding', () => { + const result = detector.analyzeUrl('https://example.com/%3f%3d%26%23%40%24%5e'); + expect(result.threats.some((t) => t.type === ThreatType.URL_ENTROPY)).toBe(true); + }); + + it('should detect high URL path entropy', () => { + const result = detector.analyzeUrl('https://example.com/a8f3k2m9x7q1w4e6r5t0y2u8i3o7p'); + expect(result.threats.some((t) => t.type === ThreatType.URL_ENTROPY)).toBe(true); + }); + + it('should return SUSPICIOUS for moderate score', () => { + const result = detector.analyzeUrl('http://goggle.com/login-secure'); + expect(result.verdict).toBe(UrlVerdict.SUSPICIOUS); + }); + + it('should return PHISHING for high score', () => { + const result = detector.analyzeUrl('http://goggle.tk/login-secure-portal?redirect=/a&next=/b'); + expect(result.verdict).toBe(UrlVerdict.PHISHING); + }); + + it('should handle malformed URLs', () => { + const result = detector.analyzeUrl('not-a-real-url'); + expect(result.verdict).toBe(UrlVerdict.UNKNOWN); + }); + + it('should detect brand impersonation patterns', () => { + const result = detector.analyzeUrl('https://account-verify-now.com/paypal'); + expect(result.threats.some((t) => t.type === ThreatType.PHISHING_HEURISTIC)).toBe(true); + }); + }); + + describe('verdict thresholds', () => { + it('should classify score < 20 as SAFE', () => { + const result = detector.analyzeUrl('https://www.microsoft.com'); + expect(result.verdict).toBe(UrlVerdict.SAFE); + }); + + it('should classify score >= 20 as SPAM', () => { + const result = detector.analyzeUrl('https://example.tk/page'); + if (result.score >= 20 && result.score < 40) { + expect(result.verdict).toBe(UrlVerdict.SPAM); + } + }); + + it('should classify score >= 40 as SUSPICIOUS', () => { + const result = detector.analyzeUrl('http://g00gle.tk/login'); + if (result.score >= 40 && result.score < 70) { + expect(result.verdict).toBe(UrlVerdict.SUSPICIOUS); + } + }); + + it('should classify score >= 70 as PHISHING', () => { + const result = detector.analyzeUrl('http://g00gle.tk/login-secure-portal?redirect=/a&next=/b'); + if (result.score >= 70) { + expect(result.verdict).toBe(UrlVerdict.PHISHING); + } + }); + }); +}); diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json new file mode 100644 index 0000000..feb565a --- /dev/null +++ b/packages/extension/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "dist", + "rootDir": "src", + "types": ["chrome"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts new file mode 100644 index 0000000..29c6d37 --- /dev/null +++ b/packages/extension/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig(({ mode }) => { + const isFirefox = mode === 'firefox'; + const targetDir = isFirefox ? 'dist/firefox' : 'dist/chrome'; + + return { + root: '.', + build: { + outDir: targetDir, + emptyOutDir: true, + rollupOptions: { + input: { + background: resolve(__dirname, 'src/background/index.ts'), + content: resolve(__dirname, 'src/content/index.ts'), + popup: resolve(__dirname, 'src/popup/popup.ts'), + options: resolve(__dirname, 'src/options/options.ts'), + }, + output: { + entryFileNames: '[name].js', + }, + }, + copyPublicDir: true, + }, + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + }; +}); diff --git a/packages/extension/vitest.config.ts b/packages/extension/vitest.config.ts new file mode 100644 index 0000000..8996a04 --- /dev/null +++ b/packages/extension/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +});