From 2ee0d5b1fa2246834919e3bc23b2010e5273a5bc Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 13 Nov 2025 23:57:25 -0500 Subject: [PATCH 01/13] Update build-assets-on-release.yml for permissions Added permissions for content write access in jobs. Signed-off-by: John Duprey --- .github/workflows/build-assets-on-release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build-assets-on-release.yml b/.github/workflows/build-assets-on-release.yml index 2eccad42..351a95d9 100644 --- a/.github/workflows/build-assets-on-release.yml +++ b/.github/workflows/build-assets-on-release.yml @@ -13,6 +13,8 @@ jobs: # build and attach it to the release and use its asset id ensure-zip: runs-on: ubuntu-latest + permissions: + contents: write outputs: zipAssetId: | ${{ steps.getZipAssetId.outputs.result || @@ -57,6 +59,8 @@ jobs: build-signed-crx-asset: needs: ensure-zip runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 @@ -89,3 +93,4 @@ jobs: asset_path: ${{ env.OFFLINE_CRX_FILE_PATH }} asset_name: ${{ env.OFFLINE_CRX_FILE_NAME }} asset_content_type: application/x-chrome-extension + From 0a3cc67c3df709ac40b90cb2a96de0ef02d507e2 Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Mon, 17 Nov 2025 14:36:13 +0100 Subject: [PATCH 02/13] Add script to remove Chrome and Edge extension settings This script removes specific Chrome and Edge extension settings from the Windows registry, including managed storage and extension settings keys. Signed-off-by: Roel van der Wegen --- enterprise/Remove-Windows-Chrome-and-Edge.ps1 | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 enterprise/Remove-Windows-Chrome-and-Edge.ps1 diff --git a/enterprise/Remove-Windows-Chrome-and-Edge.ps1 b/enterprise/Remove-Windows-Chrome-and-Edge.ps1 new file mode 100644 index 00000000..fed3c9d9 --- /dev/null +++ b/enterprise/Remove-Windows-Chrome-and-Edge.ps1 @@ -0,0 +1,139 @@ +# Define extension details (same as install-check.ps1) +# Chrome +$chromeExtensionId = "benimdeioplgkhanklclahllklceahbe" +$chromeManagedStorageKey = "HKLM:\SOFTWARE\Policies\Google\Chrome\3rdparty\extensions\$chromeExtensionId\policy" +$chromeExtensionSettingsKey = "HKLM:\SOFTWARE\Policies\Google\Chrome\ExtensionSettings\$chromeExtensionId" + +# Edge +$edgeExtensionId = "knepjpocdagponkonnbggpcnhnaikajg" +$edgeManagedStorageKey = "HKLM:\SOFTWARE\Policies\Microsoft\Edge\3rdparty\extensions\$edgeExtensionId\policy" +$edgeExtensionSettingsKey = "HKLM:\SOFTWARE\Policies\Microsoft\Edge\ExtensionSettings\$edgeExtensionId" + +# Function to remove extension settings +function Remove-ExtensionSettings { + param ( + [string]$ExtensionId, + [string]$ManagedStorageKey, + [string]$ExtensionSettingsKey + ) + + # Remove properties from managed storage key + if (Test-Path $ManagedStorageKey) { + $propertiesToRemove = @( + "showNotifications", + "enableValidPageBadge", + "enablePageBlocking", + "enableCippReporting", + "cippServerUrl", + "cippTenantId", + "customRulesUrl", + "updateInterval", + "enableDebugLogging" + ) + + foreach ($property in $propertiesToRemove) { + if (Get-ItemProperty -Path $ManagedStorageKey -Name $property -ErrorAction SilentlyContinue) { + Remove-ItemProperty -Path $ManagedStorageKey -Name $property -Force -ErrorAction SilentlyContinue + Write-Host "Removed property: $property from $ManagedStorageKey" + } + } + + # Remove URL allowlist subkey and all its properties + $urlAllowlistKey = "$ManagedStorageKey\urlAllowlist" + if (Test-Path $urlAllowlistKey) { + # Remove all numbered properties (1, 2, 3, etc.) + $properties = Get-ItemProperty -Path $urlAllowlistKey -ErrorAction SilentlyContinue + if ($properties) { + $properties.PSObject.Properties | Where-Object { $_.Name -match '^\d+$' } | ForEach-Object { + Remove-ItemProperty -Path $urlAllowlistKey -Name $_.Name -Force -ErrorAction SilentlyContinue + Write-Host "Removed URL allowlist property: $($_.Name) from $urlAllowlistKey" + } + } + # Remove the urlAllowlist subkey if it's empty + try { + Remove-Item -Path $urlAllowlistKey -Force -ErrorAction SilentlyContinue + Write-Host "Removed URL allowlist subkey: $urlAllowlistKey" + } catch { + # Key may not be empty or may have been removed already + } + } + + # Remove custom branding subkey and all its properties + $customBrandingKey = "$ManagedStorageKey\customBranding" + if (Test-Path $customBrandingKey) { + $brandingPropertiesToRemove = @( + "companyName", + "companyURL", + "productName", + "supportEmail", + "primaryColor", + "logoUrl" + ) + + foreach ($property in $brandingPropertiesToRemove) { + if (Get-ItemProperty -Path $customBrandingKey -Name $property -ErrorAction SilentlyContinue) { + Remove-ItemProperty -Path $customBrandingKey -Name $property -Force -ErrorAction SilentlyContinue + Write-Host "Removed custom branding property: $property from $customBrandingKey" + } + } + + # Remove the customBranding subkey if it's empty + try { + Remove-Item -Path $customBrandingKey -Force -ErrorAction SilentlyContinue + Write-Host "Removed custom branding subkey: $customBrandingKey" + } catch { + # Key may not be empty or may have been removed already + } + } + + # Remove the managed storage key if it's empty + try { + $remainingProperties = Get-ItemProperty -Path $ManagedStorageKey -ErrorAction SilentlyContinue + if ($remainingProperties -and $remainingProperties.PSObject.Properties.Count -eq 0) { + Remove-Item -Path $ManagedStorageKey -Force -ErrorAction SilentlyContinue + Write-Host "Removed managed storage key: $ManagedStorageKey" + } + } catch { + # Key may not be empty or may have been removed already + } + } + + # Remove properties from extension settings key + if (Test-Path $ExtensionSettingsKey) { + $extensionPropertiesToRemove = @( + "installation_mode", + "update_url" + ) + + # Add browser-specific toolbar properties + if ($ExtensionId -eq $edgeExtensionId) { + $extensionPropertiesToRemove += "toolbar_state" + } elseif ($ExtensionId -eq $chromeExtensionId) { + $extensionPropertiesToRemove += "toolbar_pin" + } + + foreach ($property in $extensionPropertiesToRemove) { + if (Get-ItemProperty -Path $ExtensionSettingsKey -Name $property -ErrorAction SilentlyContinue) { + Remove-ItemProperty -Path $ExtensionSettingsKey -Name $property -Force -ErrorAction SilentlyContinue + Write-Host "Removed extension setting property: $property from $ExtensionSettingsKey" + } + } + + # Remove the extension settings key if it's empty + try { + $remainingProperties = Get-ItemProperty -Path $ExtensionSettingsKey -ErrorAction SilentlyContinue + if ($remainingProperties -and $remainingProperties.PSObject.Properties.Count -eq 0) { + Remove-Item -Path $ExtensionSettingsKey -Force -ErrorAction SilentlyContinue + Write-Host "Removed extension settings key: $ExtensionSettingsKey" + } + } catch { + # Key may not be empty or may have been removed already + } + } + + Write-Host "Completed removal of extension settings for $ExtensionId" +} + +# Remove settings for Chrome and Edge +Remove-ExtensionSettings -ExtensionId $chromeExtensionId -ManagedStorageKey $chromeManagedStorageKey -ExtensionSettingsKey $chromeExtensionSettingsKey +Remove-ExtensionSettings -ExtensionId $edgeExtensionId -ManagedStorageKey $edgeManagedStorageKey -ExtensionSettingsKey $edgeExtensionSettingsKey From e6a906faeb30014d5ef5dc526a4c2eb10eb1d9f4 Mon Sep 17 00:00:00 2001 From: redanthrax Date: Thu, 20 Nov 2025 14:00:35 -0800 Subject: [PATCH 03/13] fix: debug logging setting, content script read from background worker instead of local storage --- options/options.html | 7 +------ options/options.js | 11 ++++------- scripts/content.js | 13 ++++++++++--- scripts/modules/config-manager.js | 6 ++++++ 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/options/options.html b/options/options.html index b5c08329..c187a190 100644 --- a/options/options.html +++ b/options/options.html @@ -299,15 +299,10 @@

Rule Playground (Beta)

-
+
+ +

For debugging: disables background worker and forces phishing detection to run on the main thread (timing and logs will be more accurate, but performance may be reduced).

+
+

Display a verification badge on legitimate Microsoft 365 login pages

+ +
+ +

Auto-dismiss timeout for the valid page badge in seconds. Set to 0 for no timeout (badge stays visible until manually dismissed).

+
@@ -250,6 +267,12 @@

Configuration Overview

Loading configuration...
+

Read-only view of the current detection rules and configuration

@@ -541,6 +564,37 @@ + + diff --git a/options/options.js b/options/options.js index a465b614..6974be00 100644 --- a/options/options.js +++ b/options/options.js @@ -52,6 +52,9 @@ class CheckOptions { this.elements.enableValidPageBadge = document.getElementById( "enableValidPageBadge" ); + this.elements.validPageBadgeTimeout = document.getElementById( + "validPageBadgeTimeout" + ); // Detection settings this.elements.customRulesUrl = document.getElementById("customRulesUrl"); @@ -194,6 +197,46 @@ class CheckOptions { } }); + // Validate timeout input + if (this.elements.validPageBadgeTimeout) { + this.elements.validPageBadgeTimeout.addEventListener("input", (e) => { + const input = e.target; + let value = input.value; + + // Remove any non-numeric characters except minus sign at start + value = value.replace(/[^\d-]/g, ''); + + // Remove minus signs (we don't allow negative numbers) + value = value.replace(/-/g, ''); + + // Parse as integer + const numValue = parseInt(value, 10); + + // If empty or NaN, clear the field + if (value === '' || isNaN(numValue)) { + input.value = ''; + return; + } + + // Enforce min/max constraints + if (numValue < 0) { + input.value = '0'; + } else if (numValue > 300) { + input.value = '300'; + } else { + input.value = numValue.toString(); + } + }); + + // Validate on blur - set to default if empty + this.elements.validPageBadgeTimeout.addEventListener("blur", (e) => { + const input = e.target; + if (input.value === '' || input.value === null) { + input.value = '5'; // Reset to default + } + }); + } + // Modal actions this.elements.modalCancel?.addEventListener("click", () => this.hideModal() @@ -863,6 +906,10 @@ class CheckOptions { this.elements.cippServerUrl = document.getElementById("cippServerUrl"); this.elements.cippTenantId = document.getElementById("cippTenantId"); + // Force main thread phishing processing (debug) + this.elements.forceMainThreadPhishingProcessing = document.getElementById("forceMainThreadPhishingProcessing"); + + if (this.elements.enablePageBlocking) { this.elements.enablePageBlocking.checked = this.config?.enablePageBlocking !== false; @@ -877,11 +924,18 @@ class CheckOptions { if (this.elements.cippTenantId) { this.elements.cippTenantId.value = this.config?.cippTenantId || ""; } + if (this.elements.forceMainThreadPhishingProcessing) { + this.elements.forceMainThreadPhishingProcessing.checked = this.config?.forceMainThreadPhishingProcessing || false; + } // UI settings this.elements.showNotifications.checked = this.config?.showNotifications; this.elements.enableValidPageBadge.checked = this.config.enableValidPageBadge || false; + this.elements.validPageBadgeTimeout.value = + this.config.validPageBadgeTimeout !== undefined + ? this.config.validPageBadgeTimeout + : 5; // Detection settings - use top-level customRulesUrl consistently this.elements.customRulesUrl.value = this.config?.customRulesUrl || ""; @@ -1120,21 +1174,32 @@ class CheckOptions { } gatherFormData() { - const formData = { + const formData = { // Extension settings enablePageBlocking: this.elements.enablePageBlocking?.checked !== false, enableCippReporting: this.elements.enableCippReporting?.checked || false, cippServerUrl: this.elements.cippServerUrl?.value || "", cippTenantId: this.elements.cippTenantId?.value || "", + // Debug: force main thread phishing processing + forceMainThreadPhishingProcessing: this.elements.forceMainThreadPhishingProcessing?.checked || false, // UI settings showNotifications: this.elements.showNotifications?.checked || false, enableValidPageBadge: this.elements.enableValidPageBadge?.checked || false, + validPageBadgeTimeout: (() => { + const value = parseInt(this.elements.validPageBadgeTimeout?.value, 10); + if (isNaN(value)) return 5; // Default if invalid + return Math.min(300, Math.max(0, value)); // Clamp to 0-300 range + })(), // Detection settings customRulesUrl: this.elements.customRulesUrl?.value || "", - updateInterval: parseInt(this.elements.updateInterval?.value || 24), + updateInterval: (() => { + const value = parseInt(this.elements.updateInterval?.value, 10); + if (isNaN(value)) return 24; // Default if invalid + return Math.min(168, Math.max(1, value)); // Clamp to 1-168 range + })(), // Generic webhook genericWebhook: { @@ -1520,11 +1585,19 @@ class CheckOptions { ) .join(""); + // Code-driven indicators summary + const codeDrivenIndicators = config.phishing_indicators.filter(r => r.code_driven); + let codeDrivenHtml = ''; + if (codeDrivenIndicators.length > 0) { + codeDrivenHtml = `
Code-Driven Indicators: ${codeDrivenIndicators.length}
`; + } + sections.push(`
Phishing Indicators (${config.phishing_indicators.length} total)
Critical Severity Rules: ${criticalCount}
${indicatorSections} + ${codeDrivenHtml}
`); } diff --git a/package.json b/package.json index b23c1109..1fc3f017 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ }, "scripts": { "test": "node --test tests/**/*.test.js", - "test:config": "node --test tests/config-persistence.test.js" + "test:config": "node --test tests/config-persistence.test.js", + "build:chrome": "node scripts/build.js chrome", + "build:firefox": "node scripts/build.js firefox" }, "keywords": [], "author": "", diff --git a/popup/popup.html b/popup/popup.html index d700da63..5fb452d3 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -241,6 +241,7 @@

Enterprise

+ diff --git a/popup/popup.js b/popup/popup.js index d5fae7f5..4598d5c4 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -16,6 +16,7 @@ class CheckPopup { this.activityItems = []; this.isLoading = false; this.isBlockedRoute = false; + this.cachedDebugData = null; // Store debug data for blocked pages to enable toggling this.elements = {}; this.bindElements(); @@ -251,6 +252,13 @@ class CheckPopup { this.elements.debugSection.style.display = "block"; this.elements.pageSourceSection.style.display = "block"; this.elements.consoleLogsSection.style.display = "block"; + + // Hide the "Re-run Analysis" button when on a blocked page + if (this.isBlockedRoute) { + this.elements.retriggerAnalysis.style.display = "none"; + } else { + this.elements.retriggerAnalysis.style.display = "inline-flex"; + } } else { this.elements.debugSection.style.display = "none"; this.elements.pageSourceSection.style.display = "none"; @@ -1375,6 +1383,18 @@ class CheckPopup { return; } + // For blocked pages, use cached debug data if available + if (this.isBlockedRoute && this.cachedDebugData && this.cachedDebugData.detectionDetails) { + console.log("Re-displaying cached debug data for blocked page"); + this.displayDetectionDetails(this.cachedDebugData.detectionDetails); + this.elements.detectionResults.style.display = "block"; + this.elements.showDetectionDetails.innerHTML = ` + visibility_off + Hide Details + `; + return; + } + if (!this.currentTab || !this.currentTab.url) { this.showNotification("No active tab to analyze", "warning"); return; @@ -1860,8 +1880,8 @@ class CheckPopup { "Retrieved stored debug data via background script for URL:", url ); - console.log("Returning debug data:", data); - return data; // Return the full data object since it IS the debug data + console.log("Returning debug data:", data.debugData); + return data.debugData; // Return the nested debugData object } else { console.log("Data too old or no timestamp, cleaning up"); // Clean up old data @@ -1946,6 +1966,9 @@ class CheckPopup { console.log("Has consoleLogs:", !!storedDebugData.consoleLogs); console.log("Has pageSource:", !!storedDebugData.pageSource); + // Cache the debug data for toggling + this.cachedDebugData = storedDebugData; + // Display detection details if available if (storedDebugData.detectionDetails) { this.displayDetectionDetails(storedDebugData.detectionDetails); diff --git a/rules/detection-rules.json b/rules/detection-rules.json index 351b3ea3..256c8fab 100644 --- a/rules/detection-rules.json +++ b/rules/detection-rules.json @@ -1,6 +1,6 @@ { - "version": "1.0.6", - "lastUpdated": "2025-09-08T14:20:00Z", + "version": "1.0.8", + "lastUpdated": "2024-12-04T12:00:00Z", "description": "Phishing detection logic for identifying phishing attempts targeting Microsoft 365 login pages", "trusted_login_patterns": [ "^https:\\/\\/login\\.microsoftonline\\.(com|us)$", @@ -125,6 +125,59 @@ } ], "secondary_elements": [ + { + "id": "page_title_microsoft", + "type": "page_title", + "patterns": [ + "microsoft\\s*365", + "office\\s*365", + "microsoft.*sign\\s*in", + "sign\\s*in.*microsoft", + "microsoft.*login", + "login.*microsoft", + "microsoft\\s*account", + "azure.*sign\\s*in", + "office.*sign\\s*in" + ], + "description": "Page title contains Microsoft branding with sign-in/login keywords", + "weight": 2, + "category": "secondary" + }, + { + "id": "meta_description_microsoft", + "type": "meta_tag", + "attribute": "description", + "patterns": [ + "microsoft\\s*365", + "office\\s*365", + "sign\\s*in.*microsoft", + "microsoft.*sign\\s*in" + ], + "description": "Meta description contains Microsoft branding", + "weight": 1, + "category": "secondary" + }, + { + "id": "meta_og_title_microsoft", + "type": "meta_tag", + "attribute": "og:title", + "patterns": [ + "microsoft", + "office\\s*365", + "azure" + ], + "description": "Open Graph title contains Microsoft branding", + "weight": 1, + "category": "secondary" + }, + { + "id": "favicon_microsoft", + "type": "source_content", + "pattern": "]+rel=[\"'](?:icon|shortcut icon|apple-touch-icon)[\"'][^>]+href=[\"'][^\"']*(?:microsoft|msft|m365\\.ico|office)[^\"']*[\"'][^>]*>", + "description": "Favicon references Microsoft branding", + "weight": 1, + "category": "secondary" + }, { "id": "ms_form_dimensions", "type": "css_pattern", @@ -146,7 +199,7 @@ "border:\\s*1px\\s+solid\\s+#0067b8" ], "description": "Microsoft specific button styling (supporting evidence only)", - "weight": 1, + "weight": 1.5, "category": "secondary" }, { @@ -181,7 +234,7 @@ "type": "source_content", "pattern": "]*(?:type=[\"']password[\"']|name=[\"']password[\"']|id=[\"'][^\"']*password[^\"']*[\"'])[^>]*>", "description": "Password input field present on page (supporting evidence only)", - "weight": 1, + "weight": 0.5, "category": "secondary" }, { @@ -189,6 +242,14 @@ "type": "source_content", "pattern": "]*type=[\"'](?:email|text|tel)[\"'][^>]*(?:placeholder=[\"'][^\"']+[\"'][^>]*|[^>]*)>", "description": "Login form input field (email/text/tel type) with placeholder attribute", + "weight": 0.5, + "category": "secondary" + }, + { + "id": "ms_login_placeholder_text", + "type": "source_content", + "pattern": "(?:Email,\\s*phone,?\\s*or\\s*Skype|Enter\\s+your\\s+email,\\s*phone,?\\s*or\\s*Skype|someone@example\\.com|example@example\\.com)", + "description": "Microsoft login placeholder text patterns - highly specific to Microsoft login pages", "weight": 1, "category": "secondary" } @@ -452,10 +513,54 @@ "category": "domain_spoofing", "confidence": 0.9 }, + { + "id": "phi_031_suspicious_query_length_combined", + "code_driven": true, + "code_logic": { + "description": "Trigger if a suspiciously long query parameter is present AND the page contains Microsoft branding keywords AND there is a password field or form submission.", + "logic": "if (url.match(/[?&][a-zA-Z0-9_\\-]{1,32}=([a-zA-Z0-9_\\-]{30,})/i) && (pageText.match(/microsoft|office|365/i)) && (document.querySelector('input[type=\\\\'password\\\\']') || document.querySelector('form'))) { return true; } return false;" + }, + "severity": "medium", + "description": "Suspiciously long query parameter value in URL, Microsoft branding, and password field or form present (possible phishing)", + "action": "warn", + "category": "url_structure", + "confidence": 0.7 + }, { "id": "phi_004", - "pattern": "(?:urgent.*(?:action|update|verify)|immediate.*(?:action|attention)|act.*(?:now|immediately)).*(?:microsoft|office|365)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "all_of", + "operations": [ + { + "type": "any_of", + "operations": [ + { + "type": "substring_proximity", + "word1": "urgent", + "word2": "action", + "max_distance": 500 + }, + { + "type": "substring_proximity", + "word1": "immediate", + "word2": "attention", + "max_distance": 500 + }, + { + "type": "substring_proximity", + "word1": "act", + "word2": "now", + "max_distance": 500 + } + ] + }, + { + "type": "substring_present", + "values": ["microsoft", "office", "365"] + } + ] + }, "severity": "medium", "description": "Urgency tactics targeting Microsoft users", "action": "warn", @@ -484,8 +589,13 @@ }, { "id": "phi_012_suspicious_resources", - "pattern": "(?=.*customcss)(?!.*aadcdn\\.msftauthimages\\.net)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "resource_from_domain", + "resource_type": "customcss", + "allowed_domains": ["aadcdn.msftauthimages.net"], + "invert": true + }, "severity": "high", "description": "Custom CSS loaded from unauthorized domain", "action": "block", @@ -494,20 +604,62 @@ }, { "id": "phi_006", - "pattern": "(?:microsoft|office|365).{0,2000}(?:login|password|signin).{0,500}form.{0,200}action(?!.*login\\.microsoftonline\\.com)(?!.*\\.auth/login/)(?!.*azure\\s+static\\s+web\\s+apps)(?!.*easy\\s+auth)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "all_of", + "operations": [ + { + "type": "substring_present", + "values": ["microsoft", "office", "365"] + }, + { + "type": "substring_present", + "values": ["login", "password", "signin"] + }, + { + "type": "pattern_count", + "patterns": ["]*action"], + "flags": "i", + "min_count": 1 + }, + { + "type": "has_but_not", + "required": ["action"], + "prohibited": [ + "login.microsoftonline.com", + ".auth/login/", + "azure static web apps", + "easy auth" + ] + } + ] + }, "severity": "high", - "description": "Microsoft-branded login form POST URL not pointing to login.microsoftonline.com", + "description": "Microsoft-branded login form not posting to Microsoft domain", "action": "warn", "category": "credential_harvesting", "confidence": 0.8 }, { "id": "phi_010_aad_fingerprint", - "pattern": "(?:loginfmt|i0116).{0,1000}(?:idSIButton9|type=[\"']submit[\"'])(?!.*login\\.microsoftonline\\.com)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "all_of", + "operations": [ + { + "type": "substring_count", + "substrings": ["loginfmt", "i0116", "idSIButton9"], + "min_count": 2 + }, + { + "type": "has_but_not", + "required": ["password"], + "prohibited": ["login.microsoftonline.com"] + } + ] + }, "severity": "critical", - "description": "AAD-like login interface detected on non-Microsoft domain", + "description": "AAD-like login interface on non-Microsoft domain", "action": "block", "category": "interface_spoofing", "confidence": 0.98 @@ -524,72 +676,155 @@ }, { "id": "phi_013_form_action_mismatch", - "pattern": "(?:microsoft|office|365).{0,1500}(?:password|passwd).{0,500}action=(?!.*login\\.microsoftonline\\.com)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "all_of", + "operations": [ + { + "type": "substring_present", + "values": ["microsoft", "office", "365"] + }, + { + "type": "substring_present", + "values": ["password", "passwd"] + }, + { + "type": "form_action_check", + "required_domains": ["login.microsoftonline.com"] + } + ] + }, "severity": "critical", - "description": "Microsoft-branded password form with non-Microsoft action URL", + "description": "Microsoft-branded password form with non-Microsoft action", "action": "block", "category": "credential_harvesting", "confidence": 0.95 }, { "id": "phi_014_devtools_blocking", - "pattern": "(?:debugger|devtools?|F12.*prevent|contextmenu.*prevent|selectstart.*prevent|setInterval.*debugger|while.*true.*debugger)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "obfuscation_check", + "indicators": [ + "debugger", + "devtools", + "devtool", + "F12", + "f12", + "contextmenu", + "selectstart", + "dragstart", + "setInterval(function(){debugger;}", + "setInterval(function(){debugger}", + "while(true){debugger;}", + "while(true){debugger}", + "while(1){debugger", + "keyCode === 123", + "keyCode==123", + "keyCode == 123", + "which === 123", + "which==123", + "which == 123", + "keyCode===0x7b", + "keyCode==0x7b", + "keyCode == 0x7b", + "console.clear()", + "console.clear", + "addEventListener('contextmenu'", + "addEventListener(\"contextmenu\"", + "oncontextmenu=\"return false\"", + "oncontextmenu='return false'", + "onselectstart=\"return false\"", + "onselectstart='return false'", + "ondragstart=\"return false\"", + "ondragstart='return false'", + "function(_0x", + "_0x506b", + "_0x", + "ctrlKey&&", + "shiftKey&&", + "preventDefault().*console", + "preventDefault().*error", + "attempt mitigated", + "Inspect element attempt mitigated", + "Console attempt mitigated", + "Right-click attempt mitigated", + "F12 attempt mitigated", + "DevTools attempt mitigated", + "antiDebug", + "anti-debug", + "enableSecurityFeatures", + "blockDevTools", + "detectDevTools", + "setInterval.*redirect", + "document.onkeydown", + "document.onkeypress", + "window.onkeydown", + "event.ctrlKey", + "event.shiftKey", + "event.keyCode" + ], + "min_matches": 2 + }, "severity": "high", "description": "Page attempts to block or detect developer tools usage", "action": "block", "category": "anti_analysis", - "confidence": 0.9, - "additional_checks": [ - "document.addEventListener('keydown'", - "event.keyCode === 123", - "event.which === 123", - "debugger;", - "setInterval(function(){debugger;}", - "while(true){debugger;}", - "console.clear()", - "addEventListener('contextmenu'", - "oncontextmenu=\"return false\"", - "onselectstart=\"return false\"", - "ondragstart=\"return false\"", - "function(_0x", - "_0x506b", - "keyCode==0x7b", - "ctrlKey&&.*shiftKey&&.*keyCode==0x", - "preventDefault().*console.*error", - "attempt mitigated", - "Inspect element attempt mitigated", - "Console attempt mitigated", - "Right-click attempt mitigated", - "antiDebug", - "enableSecurityFeatures", - "setInterval.*redirect" - ] + "confidence": 0.9 }, { "id": "phi_015_code_obfuscation", - "pattern": "(?:eval\\s*\\(\\s*(?:atob|unescape|decodeURIComponent)|(?:new\\s+)?Function\\s*\\([^)]{0,100}atob|setInterval\\s*\\([^)]{0,100}(?:atob|eval)|setTimeout\\s*\\([^)]{0,100}(?:atob|eval)|document\\.write\\s*\\([^)]{0,100}(?:atob|unescape))", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "all_of", + "operations": [ + { + "type": "obfuscation_check", + "indicators": [ + "eval(atob(", + "eval(unescape(", + "eval(decodeURIComponent(", + "eval (atob(", + "eval (unescape(", + "Function(atob(", + "Function(unescape(", + "new Function(atob(", + "new Function(unescape(", + "setInterval(eval(", + "setTimeout(eval(", + "setInterval(atob(", + "setTimeout(atob(", + "document.write(atob(", + "document.write(unescape(", + "document.write(decodeURIComponent(", + ".innerHTML=atob(", + ".innerHTML=unescape(", + ".innerHTML=eval(", + "String.fromCharCode", + "fromCharCode" + ], + "min_matches": 1 + }, + { + "type": "substring_present", + "values": [ + "microsoft", + "office", + "365", + "login", + "password", + "credential", + "signin", + "sign-in" + ] + } + ] + }, "severity": "high", "description": "Page contains suspicious JavaScript obfuscation patterns commonly used in malware", "action": "warn", "category": "code_obfuscation", - "confidence": 0.85, - "context_required": [ - "(?:microsoft|office|365|login|password|credential)" - ], - "additional_checks": [ - "eval(atob(", - "eval(unescape(", - "eval(decodeURIComponent(", - "Function(atob(", - "new Function(atob(", - "setInterval(eval(", - "setTimeout(eval(", - "document.write(atob(", - "document.write(unescape(" - ] + "confidence": 0.85 }, { "id": "phi_008", @@ -603,6 +838,20 @@ }, { "id": "phi_019_malicious_obfuscation", + "code_driven": true, + "code_logic": { + "type": "substring_or_regex", + "substrings": [ + "atob(", + "unescape(", + "eval(", + ".split('')", + ".reverse()", + "String.fromCharCode(" + ], + "regex": "(?:(?:var|let|const)\\s+\\w+\\s*=\\s*(?:atob|unescape)\\([^)]+\\);\\s*eval\\(\\w+\\)|\\w+\\.split\\(['\"]['\"]\\)\\.reverse\\(\\)\\.join\\(['\"]['\"]\\)|String\\.fromCharCode\\((?:\\d+,\\s*){10,}\\d+\\))", + "flags": "i" + }, "pattern": "(?:(?:var|let|const)\\s+\\w+\\s*=\\s*(?:atob|unescape)\\([^)]+\\);\\s*eval\\(\\w+\\)|\\w+\\.split\\(['\"]['\"]\\)\\.reverse\\(\\)\\.join\\(['\"]['\"]\\)|String\\.fromCharCode\\((?:\\d+,\\s*){10,}\\d+\\))", "flags": "i", "severity": "critical", @@ -613,8 +862,56 @@ }, { "id": "phi_001_enhanced", - "pattern": "(?!.*(?:sign\\s+in\\s+with\\s+microsoft|continue\\s+with\\s+microsoft|login\\s+with\\s+microsoft|authenticate\\s+with\\s+microsoft|sso\\s+microsoft|oauth\\s+microsoft|\\.auth/login/|azure\\s+static\\s+web\\s+apps|easy\\s+auth))(?:secure-?(?:microsoft|office|365|outlook)|microsoft-?(?:secure|login|auth)|office-?(?:secure|login|auth)|365-?(?:secure|login|auth))", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "has_but_not", + "required": [ + "secure-microsoft", + "secure-office", + "secure-365", + "secure-outlook", + "securemicrosoft", + "secureoffice", + "secure365", + "secureoutlook", + "microsoft-secure", + "microsoft-login", + "microsoft-auth", + "microsoftsecure", + "microsoftlogin", + "microsoftauth", + "office-secure", + "office-login", + "office-auth", + "officesecure", + "officelogin", + "officeauth", + "365-secure", + "365-login", + "365-auth", + "365secure", + "365login", + "365auth", + "outlook-secure", + "outlook-login", + "outlooksecure", + "outlooklogin" + ], + "prohibited": [ + "sign in with microsoft", + "continue with microsoft", + "login with microsoft", + "authenticate with microsoft", + "sso microsoft", + "oauth microsoft", + "third party auth", + "third-party auth", + ".auth/login/", + "azure static web apps", + "easy auth", + "easyauth" + ] + }, "severity": "critical", "description": "Enhanced detection of domains mimicking Microsoft services with security/login keywords (excludes legitimate SSO)", "action": "block", @@ -623,8 +920,79 @@ }, { "id": "phi_002", - "pattern": "(?!.*(?:sign\\s+in\\s+with\\s+microsoft|continue\\s+with\\s+microsoft|login\\s+with\\s+microsoft|sso|oauth|third.?party\\s+auth|\\.auth/login/|azure\\s+static\\s+web\\s+apps|easy\\s+auth))(?:microsoft|office|365).{0,500}(?:security|verification|account).{0,300}(?:team|department|support)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "all_of", + "operations": [ + { + "type": "substring_present", + "values": ["microsoft", "office", "365", "outlook", "azure"] + }, + { + "type": "any_of", + "operations": [ + { + "type": "substring_proximity", + "word1": "security", + "word2": "team", + "max_distance": 300 + }, + { + "type": "substring_proximity", + "word1": "security", + "word2": "department", + "max_distance": 300 + }, + { + "type": "substring_proximity", + "word1": "security", + "word2": "support", + "max_distance": 300 + }, + { + "type": "substring_proximity", + "word1": "verification", + "word2": "team", + "max_distance": 300 + }, + { + "type": "substring_proximity", + "word1": "verification", + "word2": "department", + "max_distance": 300 + }, + { + "type": "substring_proximity", + "word1": "account", + "word2": "team", + "max_distance": 300 + }, + { + "type": "substring_proximity", + "word1": "account", + "word2": "support", + "max_distance": 300 + } + ] + }, + { + "type": "has_but_not", + "required": ["team", "department", "support"], + "prohibited": [ + "sign in with microsoft", + "continue with microsoft", + "login with microsoft", + "sso", + "oauth", + "third party auth", + "third-party auth", + ".auth/login/", + "azure static web apps", + "easy auth" + ] + } + ] + }, "severity": "high", "description": "Impersonation of Microsoft security team (excludes legitimate SSO and third-party auth)", "action": "block", @@ -633,8 +1001,35 @@ }, { "id": "phi_003", - "pattern": "(?!.*(?:sign\\s+in\\s+with\\s+microsoft|continue\\s+with\\s+microsoft|login\\s+with\\s+microsoft|authenticate\\s+with\\s+microsoft|sso|oauth|third.?party\\s+auth|\\.auth/login/|azure\\s+static\\s+web\\s+apps|easy\\s+auth))(?:verify.{0,200}account|suspended.{0,200}365|update.{0,200}office|secure.{0,200}microsoft|account.{0,200}security|security.{0,200}verification|365.{0,200}suspended|office.{0,200}update|microsoft.{0,200}secure|login.{0,200}microsoft|microsoft.{0,200}login|microsoft.{0,200}authentication|authentication.{0,200}microsoft|office.{0,200}365.{0,200}login|365.{0,200}office.{0,200}login)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "multi_proximity", + "pairs": [ + {"words": ["verify", "account"], "max_distance": 50}, + {"words": ["verify", "information"], "max_distance": 50}, + {"words": ["verify", "identity"], "max_distance": 50}, + {"words": ["suspended", "365"], "max_distance": 50}, + {"words": ["suspended", "account"], "max_distance": 50}, + {"words": ["suspended", "office"], "max_distance": 50}, + {"words": ["update", "office"], "max_distance": 50}, + {"words": ["update", "microsoft"], "max_distance": 50}, + {"words": ["update", "365"], "max_distance": 50}, + {"words": ["secure", "microsoft"], "max_distance": 50}, + {"words": ["secure", "account"], "max_distance": 50}, + {"words": ["account", "security"], "max_distance": 50}, + {"words": ["security", "verification"], "max_distance": 50}, + {"words": ["security", "alert"], "max_distance": 50}, + {"words": ["login", "microsoft"], "max_distance": 50}, + {"words": ["microsoft", "login"], "max_distance": 50}, + {"words": ["microsoft", "authentication"], "max_distance": 50}, + {"words": ["authentication", "microsoft"], "max_distance": 50}, + {"words": ["office", "365"], "max_distance": 50}, + {"words": ["365", "login"], "max_distance": 50}, + {"words": ["office", "login"], "max_distance": 50}, + {"words": ["365", "suspended"], "max_distance": 50}, + {"words": ["office", "suspended"], "max_distance": 50} + ] + }, "severity": "high", "description": "Common Microsoft 365 phishing keywords and variations", "action": "block", @@ -643,10 +1038,25 @@ }, { "id": "phi_020_grammar_typos", - "pattern": "(?:verify\\s+your\\s+informations|click\\s+hear|recieve|loose\\s+access|acount|secuirty|authentification|guarentee|occured|seperate)", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "substring_count", + "substrings": [ + "informations", + "click hear", + "recieve", + "loose access", + "acount", + "secuirty", + "authentification", + "guarentee", + "occured", + "seperate" + ], + "min_count": 2 + }, "severity": "medium", - "description": "Common grammar/spelling errors in phishing content", + "description": "Multiple grammar/spelling errors indicative of phishing", "action": "warn", "category": "content_quality", "confidence": 0.7 @@ -676,13 +1086,123 @@ }, { "id": "phi_017_microsoft_brand_abuse", - "pattern": "(?!.*(?:sign\\s+in\\s+with\\s+microsoft|continue\\s+with\\s+microsoft|login\\s+with\\s+microsoft|authenticate\\s+with\\s+microsoft|sso|oauth|third.?party\\s+auth|\\.auth/login/|azure\\s+static\\s+web\\s+apps|easy\\s+auth|discussion|forum|community|tutorial|guide|documentation|support|help|wiki|blog|article|how\\s+to|step\\s+by\\s+step|configure|setup|administration|management))(?:(?:microsoft|office|365).{0,1000}(?:login|sign.{0,50}in|authentication)|(?:login|sign.{0,50}in|authentication).{0,1000}(?:microsoft|office|365))", - "flags": "i", + "code_driven": true, + "code_logic": { + "type": "all_of", + "operations": [ + { + "type": "multi_proximity", + "pairs": [ + { + "words": ["microsoft", "login"], + "max_distance": 750 + }, + { + "words": ["office", "sign in"], + "max_distance": 750 + }, + { + "words": ["365", "authentication"], + "max_distance": 750 + } + ] + }, + { + "type": "has_but_not", + "required": ["login", "sign"], + "prohibited": [ + "sign in with microsoft", + "continue with microsoft", + "sso", + "oauth", + ".auth/login/", + "easy auth", + "discussion", + "forum", + "tutorial", + "documentation" + ] + } + ] + }, "severity": "high", - "description": "Microsoft branding combined with login/authentication terms on non-Microsoft domain", + "description": "Microsoft branding combined with authentication terms on non-Microsoft domain", "action": "block", "category": "brand_abuse", "confidence": 0.95 + }, + { + "id": "phi_023_css_selection_blocking", + "code_driven": true, + "code_logic": { + "type": "substring_present", + "values": [ + "user-select: none", + "-webkit-user-select: none", + "-moz-user-select: none", + "-ms-user-select: none" + ] + }, + "severity": "high", + "description": "CSS prevents text selection - anti-analysis technique", + "action": "block", + "category": "anti_analysis", + "confidence": 0.85 + }, + { + "id": "phi_024_randomized_css_classes", + "pattern": "class\\s*=\\s*[\"'][a-z]+_[a-z]+_\\d{3}[\"']", + "flags": "g", + "severity": "medium", + "description": "Randomized CSS class names to evade pattern detection", + "action": "warn", + "category": "code_obfuscation", + "confidence": 0.75 + }, + { + "id": "phi_025_honeypot_fields", + "pattern": "(?:position\\s*:\\s*absolute\\s*!important\\s*;[^}]*left\\s*:\\s*\\-9999px)|(?:visibility\\s*:\\s*hidden\\s*!important)|(?:opacity\\s*:\\s*0\\s*!important[^}]*width\\s*:\\s*0)", + "flags": "i", + "severity": "high", + "description": "Honeypot fields used to detect and filter automated bot submissions", + "action": "block", + "category": "anti_analysis", + "confidence": 0.9 + }, + { + "id": "phi_029_fake_dead_links", + "code_driven": true, + "code_logic": { + "type": "pattern_count", + "patterns": [ + "]*href\\s*=\\s*[\"'](?:#|javascript:)[\"'][^>]*>(?:[^<]*){2,}" + ], + "flags": "i", + "min_count": 1 + }, + "severity": "high", + "description": "Obfuscated links with empty tags - phishing technique", + "action": "block", + "category": "suspicious_structure", + "confidence": 0.95 + }, + { + "id": "phi_030_empty_tag_obfuscation", + "code_driven": true, + "code_logic": { + "type": "pattern_count", + "patterns": [ + "(?:){5,}", + "(?:){5,}" + ], + "flags": "i", + "min_count": 1 + }, + "severity": "high", + "description": "Multiple empty tags used to obfuscate text", + "action": "block", + "category": "text_obfuscation", + "confidence": 0.9 } ], "legitimate_patterns": [ diff --git a/scripts/background.js b/scripts/background.js index 6155e136..b99eb372 100644 --- a/scripts/background.js +++ b/scripts/background.js @@ -4,6 +4,9 @@ * Enhanced with Check, CyberDrain's Microsoft 365 phishing detection */ +// Import browser polyfill for cross-browser compatibility (Chrome/Firefox) +import { chrome, storage } from "./browser-polyfill.js"; + import { ConfigManager } from "./modules/config-manager.js"; import { PolicyManager } from "./modules/policy-manager.js"; import { DetectionRulesManager } from "./modules/detection-rules-manager.js"; @@ -149,7 +152,7 @@ class RogueAppsManager { async loadFromCache() { try { - const result = await safe(chrome.storage.local.get([this.cacheKey])); + const result = await safe(storage.local.get([this.cacheKey])); const cached = result?.[this.cacheKey]; if (cached && cached.apps && cached.lastUpdate) { @@ -217,7 +220,7 @@ class RogueAppsManager { // Save to storage await safe( - chrome.storage.local.set({ + storage.local.set({ [this.cacheKey]: { apps: apps, lastUpdate: this.lastUpdate, @@ -557,6 +560,39 @@ class CheckBackground { } } + // Send event to webhook (wrapper for webhookManager.sendWebhook) + async sendEvent(eventData) { + try { + // Map event types to webhook types + const eventTypeMap = { + "trusted-login-page": this.webhookManager.webhookTypes.VALIDATION_EVENT, + "phishy-detected": this.webhookManager.webhookTypes.THREAT_DETECTED, + "page-blocked": this.webhookManager.webhookTypes.PAGE_BLOCKED, + "rogue-app-detected": this.webhookManager.webhookTypes.ROGUE_APP, + "detection-alert": this.webhookManager.webhookTypes.DETECTION_ALERT, + }; + + const webhookType = eventTypeMap[eventData.type]; + if (!webhookType) { + logger.warn(`Unknown event type: ${eventData.type}`); + return; + } + + // Get metadata + const metadata = { + timestamp: new Date().toISOString(), + extensionVersion: chrome.runtime.getManifest().version, + ...eventData.metadata, + }; + + // Send webhook + await this.webhookManager.sendWebhook(webhookType, eventData, metadata); + } catch (error) { + // Log error but don't throw - webhook failures shouldn't break functionality + logger.error(`Failed to send event ${eventData.type}:`, error); + } + } + // CyberDrain integration - Remove valid badges from all tabs when setting is disabled async removeValidBadgesFromAllTabs() { try { @@ -692,13 +728,13 @@ class CheckBackground { // CyberDrain integration - Handle tab activation for badge updates with safe wrappers chrome.tabs.onActivated.addListener(async ({ tabId }) => { - const data = await safe(chrome.storage.session.get("verdict:" + tabId)); + const data = await safe(storage.session.get("verdict:" + tabId)); const verdict = data?.["verdict:" + tabId]?.verdict || "not-evaluated"; this.setBadge(tabId, verdict); }); // Handle storage changes (for enterprise policy updates) - chrome.storage.onChanged.addListener((changes, namespace) => { + storage.onChanged.addListener((changes, namespace) => { this.handleStorageChange(changes, namespace); }); @@ -775,7 +811,7 @@ class CheckBackground { async _doFlush() { const cur = (await safe( - chrome.storage.local.get(["accessLogs", "securityEvents"]) + storage.local.get(["accessLogs", "securityEvents"]) )) || {}; const access = (cur.accessLogs || []) .concat(this.pendingLocal.accessLogs) @@ -787,7 +823,7 @@ class CheckBackground { this.pendingLocal.securityEvents.length = 0; const payload = { accessLogs: access, securityEvents: sec }; if (JSON.stringify(payload).length <= 4 * 1024 * 1024) { - await safe(chrome.storage.local.set(payload)); + await safe(storage.local.set(payload)); } } @@ -833,7 +869,7 @@ class CheckBackground { // Check if there's already a more specific verdict (like rogue-app) const existingData = await safe( - chrome.storage.session.get("verdict:" + tabId) + storage.session.get("verdict:" + tabId) ); const existingVerdict = existingData?.["verdict:" + tabId]?.verdict; @@ -850,7 +886,7 @@ class CheckBackground { } β†’ ${urlBasedVerdict}` ); await safe( - chrome.storage.session.set({ + storage.session.set({ ["verdict:" + tabId]: { verdict: urlBasedVerdict, url: tab.url }, }) ); @@ -931,7 +967,7 @@ class CheckBackground { if (sender.tab?.id) { const tabId = sender.tab.id; await safe( - chrome.storage.session.set({ + storage.session.set({ ["verdict:" + tabId]: { verdict: "phishy", url: sender.tab.url, @@ -955,7 +991,7 @@ class CheckBackground { if (sender.tab?.id) { const tabId = sender.tab.id; await safe( - chrome.storage.session.set({ + storage.session.set({ ["verdict:" + tabId]: { verdict: "trusted", url: sender.tab.url, @@ -983,7 +1019,7 @@ class CheckBackground { if (sender.tab?.id) { const tabId = sender.tab.id; await safe( - chrome.storage.session.set({ + storage.session.set({ ["verdict:" + tabId]: { verdict: "ms-login-unknown", url: sender.tab.url, @@ -1015,7 +1051,7 @@ class CheckBackground { ); await safe( - chrome.storage.session.set({ + storage.session.set({ ["verdict:" + tabId]: { verdict: "rogue-app", url: sender.tab.url, @@ -1045,7 +1081,7 @@ class CheckBackground { if (sender.tab?.id) { const tabId = sender.tab.id; await safe( - chrome.storage.session.set({ + storage.session.set({ ["verdict:" + tabId]: { verdict: "safe", url: sender.tab.url, @@ -1145,13 +1181,13 @@ class CheckBackground { case "GET_STORED_DEBUG_DATA": try { - // Retrieve stored debug data from chrome.storage.local + // Retrieve stored debug data from storage.local if (message.key) { console.log( "Background: Retrieving debug data for key:", message.key ); - const result = await chrome.storage.local.get([message.key]); + const result = await storage.local.get([message.key]); const debugData = result[message.key]; console.log("Background: Retrieved data:", debugData); @@ -1330,15 +1366,7 @@ class CheckBackground { case "GET_POLICIES": try { // Test managed storage directly - const managedPolicies = await new Promise((resolve, reject) => { - chrome.storage.managed.get(null, (result) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - } else { - resolve(result); - } - }); - }); + const managedPolicies = await storage.managed.get(null); // Also get enterprise config from config manager const enterpriseConfig = @@ -1858,8 +1886,6 @@ class CheckBackground { return results; } - // Test methods removed - DetectionEngine functionality moved to content script - // Test methods removed - DetectionEngine functionality moved to content script async runComprehensiveTest() { return { @@ -1922,7 +1948,7 @@ class CheckBackground { logger.log("Profile information loaded:", this.profileInfo); // Store profile info for access by other parts of the extension - await chrome.storage.local.set({ + await storage.local.set({ currentProfile: this.profileInfo, }); } catch (error) { @@ -1940,12 +1966,12 @@ class CheckBackground { async getOrCreateProfileId() { try { - const result = await chrome.storage.local.get(["profileId"]); + const result = await storage.local.get(["profileId"]); if (!result.profileId) { // Generate a unique identifier for this profile const profileId = crypto.randomUUID(); - await chrome.storage.local.set({ profileId }); + await storage.local.set({ profileId }); logger.log("Generated new profile ID:", profileId); return profileId; } @@ -1959,27 +1985,20 @@ class CheckBackground { } async checkManagedEnvironment() { - return new Promise((resolve) => { - try { - chrome.storage.managed.get(null, (policies) => { - if (chrome.runtime.lastError) { - resolve(false); - } else { - const isManaged = policies && Object.keys(policies).length > 0; - if (isManaged) { - logger.log( - "Detected managed environment with policies:", - policies - ); - } - resolve(isManaged); - } - }); - } catch (error) { - logger.error("Error checking managed environment:", error); - resolve(false); + try { + const policies = await storage.managed.get(null); + const isManaged = policies && Object.keys(policies).length > 0; + if (isManaged) { + logger.log( + "Detected managed environment with policies:", + policies + ); } - }); + return isManaged; + } catch (error) { + logger.error("Error checking managed environment:", error); + return false; + } } async getUserInfo() { @@ -2288,7 +2307,7 @@ class CheckBackground { // Get all logs from storage const result = await safe( - chrome.storage.local.get(["securityEvents", "accessLogs", "debugLogs"]) + storage.local.get(["securityEvents", "accessLogs", "debugLogs"]) ); const securityEvents = result?.securityEvents || []; diff --git a/scripts/browser-polyfill-inline.js b/scripts/browser-polyfill-inline.js new file mode 100644 index 00000000..788f7241 --- /dev/null +++ b/scripts/browser-polyfill-inline.js @@ -0,0 +1,149 @@ +/** + * Browser API Polyfill (Non-Module Version) + * For use in content scripts, popup, and options pages + * + * This provides Firefox compatibility by wrapping chrome.* APIs + * and handling chrome.storage.session fallback for Firefox. + */ + +(function() { + 'use strict'; + + // Detect browser environment + const isFirefox = typeof browser !== 'undefined' && browser.runtime; + const isChrome = typeof chrome !== 'undefined' && chrome.runtime; + + // Use browser namespace if available (Firefox), otherwise chrome namespace + const browserAPI = isFirefox ? browser : (isChrome ? chrome : {}); + + // Session storage fallback using local storage with prefix + const sessionPrefix = '__session__'; + const sessionKeys = new Set(); + + // Set up session storage polyfill for browsers that don't support it natively + // Only Firefox needs this polyfill; Chrome 88+ always supports chrome.storage.session + const needsSessionPolyfill = isFirefox; + + // Ensure chrome API exists for Firefox - do this first before session polyfill + if (isFirefox && !window.chrome) { + window.chrome = { + storage: { + local: browser.storage.local, + managed: browser.storage.managed + }, + runtime: browser.runtime, + tabs: browser.tabs, + action: browser.action, + scripting: browser.scripting, + webRequest: browser.webRequest, + alarms: browser.alarms, + identity: browser.identity + }; + } + + if (needsSessionPolyfill) { + // Create session storage polyfill using local storage + // Use the appropriate storage API based on browser + const getStorage = (keys, callback) => { + if (isFirefox) { + // Firefox uses promises + browser.storage.local.get(keys).then(callback).catch((err) => { + console.error('Firefox storage.local.get error:', err); + callback({}); + }); + } else { + // Chrome uses callbacks + chrome.storage.local.get(keys, (result) => { + if (chrome.runtime.lastError) { + callback({}); + } else { + callback(result); + } + }); + } + }; + + const setStorage = (items, callback) => { + if (isFirefox) { + browser.storage.local.set(items).then(() => callback && callback()).catch((err) => { + console.error('Firefox storage.local.set error:', err); + callback && callback(); + }); + } else { + chrome.storage.local.set(items, callback); + } + }; + + const removeStorage = (keys, callback) => { + if (isFirefox) { + browser.storage.local.remove(keys).then(() => callback && callback()).catch((err) => { + console.error('Firefox storage.local.remove error:', err); + callback && callback(); + }); + } else { + chrome.storage.local.remove(keys, callback); + } + }; + + chrome.storage.session = { + get: function(keys, callback) { + const prefixedKeys = Array.isArray(keys) + ? keys.map(k => sessionPrefix + k) + : (typeof keys === 'string' ? sessionPrefix + keys : null); + + getStorage(prefixedKeys, function(result) { + const unprefixed = {}; + if (Array.isArray(prefixedKeys)) { + for (const prefixedKey of prefixedKeys) { + const originalKey = prefixedKey.replace(sessionPrefix, ''); + if (prefixedKey in result) { + unprefixed[originalKey] = result[prefixedKey]; + } + } + } else if (prefixedKeys) { + const originalKey = prefixedKeys.replace(sessionPrefix, ''); + if (prefixedKeys in result) { + unprefixed[originalKey] = result[prefixedKeys]; + } + } else { + // Get all session keys + for (const [key, value] of Object.entries(result)) { + if (key.startsWith(sessionPrefix)) { + unprefixed[key.replace(sessionPrefix, '')] = value; + } + } + } + + callback && callback(unprefixed); + }); + }, + + set: function(items, callback) { + const prefixed = {}; + for (const [key, value] of Object.entries(items)) { + const prefixedKey = sessionPrefix + key; + prefixed[prefixedKey] = value; + sessionKeys.add(prefixedKey); + } + + setStorage(prefixed, callback); + }, + + remove: function(keys, callback) { + const keysArray = Array.isArray(keys) ? keys : [keys]; + const prefixedKeys = keysArray.map(k => sessionPrefix + k); + + prefixedKeys.forEach(k => sessionKeys.delete(k)); + + removeStorage(prefixedKeys, callback); + } + }; + } + + // Expose helper flags + window.__browserPolyfill = { + isFirefox: isFirefox, + isChrome: isChrome, + browserAPI: browserAPI + }; +})(); diff --git a/scripts/browser-polyfill.js b/scripts/browser-polyfill.js new file mode 100644 index 00000000..ed3ed78c --- /dev/null +++ b/scripts/browser-polyfill.js @@ -0,0 +1,312 @@ +/** + * Browser API Polyfill for Cross-Browser Compatibility + * + * Provides a unified API that works across Chrome, Edge, and Firefox. + * Handles differences in: + * - chrome vs browser namespace + * - callback-based vs promise-based APIs + * - chrome.storage.session (Chrome-only) fallback to local storage + */ + +// Detect browser environment +const isFirefox = typeof browser !== 'undefined' && browser.runtime; +const isChrome = typeof chrome !== 'undefined' && chrome.runtime; + +/** + * Unified browser API that works across Chrome and Firefox + * Uses browser namespace if available (Firefox), otherwise chrome namespace + */ +const browserAPI = (() => { + // Firefox has native 'browser' namespace with promises + if (isFirefox) { + return browser; + } + + // Chrome uses 'chrome' namespace - we'll wrap it where needed + if (isChrome) { + return chrome; + } + + // Fallback if neither is available (testing environment) + return {}; +})(); + +/** + * Storage API wrapper with session storage fallback for Firefox + * + * Firefox doesn't support chrome.storage.session in MV3 yet, + * so we use chrome.storage.local with a special prefix for session-like data + */ +const storageAPI = { + local: { + get: (keys) => { + if (isFirefox) { + // Firefox browser.storage.local returns promises natively + return browserAPI.storage.local.get(keys); + } + // Chrome uses callbacks, wrap in promise + return new Promise((resolve, reject) => { + browserAPI.storage.local.get(keys, (result) => { + if (browserAPI.runtime.lastError) { + reject(new Error(browserAPI.runtime.lastError.message)); + } else { + resolve(result); + } + }); + }); + }, + + set: (items) => { + if (isFirefox) { + return browserAPI.storage.local.set(items); + } + return new Promise((resolve, reject) => { + browserAPI.storage.local.set(items, () => { + if (browserAPI.runtime.lastError) { + reject(new Error(browserAPI.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + }, + + remove: (keys) => { + if (isFirefox) { + return browserAPI.storage.local.remove(keys); + } + return new Promise((resolve, reject) => { + browserAPI.storage.local.remove(keys, () => { + if (browserAPI.runtime.lastError) { + reject(new Error(browserAPI.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + }, + + clear: () => { + if (isFirefox) { + return browserAPI.storage.local.clear(); + } + return new Promise((resolve, reject) => { + browserAPI.storage.local.clear(() => { + if (browserAPI.runtime.lastError) { + reject(new Error(browserAPI.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + } + }, + + session: (() => { + // Session storage fallback for Firefox + // Uses local storage with __session__ prefix and in-memory cleanup + const _sessionPrefix = '__session__'; + const _sessionKeys = new Set(); + let _cleanupComplete = false; + let _cleanupPromise = null; + + // Initialize cleanup (called externally) + const initCleanup = async () => { + if (isFirefox && !_cleanupPromise) { + _cleanupPromise = (async () => { + // Clear all session data on startup + const allData = await storageAPI.local.get(null); + const sessionKeys = Object.keys(allData).filter(k => k.startsWith(_sessionPrefix)); + if (sessionKeys.length > 0) { + await storageAPI.local.remove(sessionKeys); + } + _sessionKeys.clear(); + _cleanupComplete = true; + })(); + } else if (isChrome) { + // Chrome uses native session storage, mark as complete immediately + _cleanupComplete = true; + } + return _cleanupPromise; + }; + + // Ensure cleanup is complete before operations + const ensureCleanup = async () => { + if (_cleanupComplete) return; + if (_cleanupPromise) return _cleanupPromise; + // This shouldn't happen if initCleanup is called on load, but handle it gracefully + if (typeof console !== 'undefined') { + console.warn('Session cleanup called before initialization - initializing now'); + } + return initCleanup(); + }; + + return { + /** + * IMPORTANT: Do not destructure these methods (e.g., const {get} = storage.session) + * They rely on closure-scoped variables (_sessionPrefix, _sessionKeys) and will + * not work correctly if called without the proper context. + * + * Correct usage: storage.session.get(keys) + * Incorrect usage: const {get} = storage.session; get(keys) // Will fail + */ + get: async (keys) => { + // Wait for cleanup to complete in Firefox + await ensureCleanup(); + + // Chrome has native session storage + if (isChrome && browserAPI.storage.session) { + return new Promise((resolve, reject) => { + browserAPI.storage.session.get(keys, (result) => { + if (browserAPI.runtime.lastError) { + reject(new Error(browserAPI.runtime.lastError.message)); + } else { + resolve(result); + } + }); + }); + } + + // Firefox fallback: use local storage with session prefix + const prefixedKeys = Array.isArray(keys) + ? keys.map(k => _sessionPrefix + k) + : (typeof keys === 'string' ? _sessionPrefix + keys : null); + + if (prefixedKeys === null) { + // Get all session keys + const allKeys = Array.from(_sessionKeys); + const result = await storageAPI.local.get(allKeys); + const unprefixed = {}; + for (const [key, value] of Object.entries(result)) { + unprefixed[key.replace(_sessionPrefix, '')] = value; + } + return unprefixed; + } + + const result = await storageAPI.local.get(prefixedKeys); + const unprefixed = {}; + + if (Array.isArray(prefixedKeys)) { + for (const prefixedKey of prefixedKeys) { + const originalKey = prefixedKey.replace(_sessionPrefix, ''); + if (prefixedKey in result) { + unprefixed[originalKey] = result[prefixedKey]; + } + } + } else { + const originalKey = prefixedKeys.replace(_sessionPrefix, ''); + if (prefixedKeys in result) { + unprefixed[originalKey] = result[prefixedKeys]; + } + } + + return unprefixed; + }, + + set: async (items) => { + // Wait for cleanup to complete in Firefox + await ensureCleanup(); + + // Chrome has native session storage + if (isChrome && browserAPI.storage.session) { + return new Promise((resolve, reject) => { + browserAPI.storage.session.set(items, () => { + if (browserAPI.runtime.lastError) { + reject(new Error(browserAPI.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + } + + // Firefox fallback: prefix keys and track them + const prefixed = {}; + for (const [key, value] of Object.entries(items)) { + const prefixedKey = _sessionPrefix + key; + prefixed[prefixedKey] = value; + _sessionKeys.add(prefixedKey); + } + + return storageAPI.local.set(prefixed); + }, + + remove: async (keys) => { + // Wait for cleanup to complete in Firefox + await ensureCleanup(); + + // Chrome has native session storage + if (isChrome && browserAPI.storage.session) { + return new Promise((resolve, reject) => { + browserAPI.storage.session.remove(keys, () => { + if (browserAPI.runtime.lastError) { + reject(new Error(browserAPI.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + } + + // Firefox fallback: prefix keys and remove + const keysArray = Array.isArray(keys) ? keys : [keys]; + const prefixedKeys = keysArray.map(k => _sessionPrefix + k); + + prefixedKeys.forEach(k => _sessionKeys.delete(k)); + + return storageAPI.local.remove(prefixedKeys); + }, + + // Exposed for external initialization + _initCleanup: initCleanup + }; + })(), + + managed: { + get: (keys) => { + if (isFirefox) { + return browserAPI.storage.managed.get(keys); + } + return new Promise((resolve, reject) => { + browserAPI.storage.managed.get(keys, (result) => { + if (browserAPI.runtime.lastError) { + reject(new Error(browserAPI.runtime.lastError.message)); + } else { + resolve(result); + } + }); + }); + } + }, + + onChanged: browserAPI.storage?.onChanged +}; + +// Initialize session cleanup on load (Firefox only) +if (isFirefox && browserAPI.storage) { + storageAPI.session._initCleanup().catch((err) => { + // Log cleanup errors to the console in development for easier debugging. + // In production, you may want to suppress this or handle differently. + if (typeof console !== 'undefined') { + console.error('Session cleanup initialization failed:', err); + } + }); +} + +/** + * Export unified API + */ +export { + browserAPI as chrome, + storageAPI as storage, + isFirefox, + isChrome +}; + +// Also export as default for convenience +export default { + chrome: browserAPI, + storage: storageAPI, + isFirefox, + isChrome +}; diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 00000000..5300635b --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +/** + * Build script for creating browser-specific extension packages + * Supports both Chrome and Firefox builds with appropriate manifest files + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.join(__dirname, '..'); + +// Parse command line arguments +const args = process.argv.slice(2); +const browser = args.find(arg => arg === 'chrome' || arg === 'firefox') || 'chrome'; + +console.log(`Building extension for ${browser}...`); + +// Paths +const manifestPath = path.join(rootDir, 'manifest.json'); +const firefoxManifestPath = path.join(rootDir, 'manifest.firefox.json'); +const manifestBackupPath = path.join(rootDir, 'manifest.chrome.json'); + +try { + if (browser === 'firefox') { + // For Firefox build + console.log('Configuring for Firefox...'); + + // Backup original manifest as Chrome version if not already done + if (!fs.existsSync(manifestBackupPath)) { + console.log('Backing up Chrome manifest...'); + fs.copyFileSync(manifestPath, manifestBackupPath); + } + + // Copy Firefox manifest + console.log('Copying Firefox manifest...'); + fs.copyFileSync(firefoxManifestPath, manifestPath); + + console.log('βœ“ Firefox build configured'); + console.log(''); + console.log('Firefox-specific changes:'); + console.log(' - Using background.scripts instead of service_worker'); + console.log(' - Removed file:/// protocol from content_scripts'); + console.log(' - Changed options_page to options_ui'); + console.log(' - Added browser_specific_settings with gecko ID'); + console.log(' - Removed identity.email permission (not needed in Firefox)'); + console.log(''); + console.log('Test the extension:'); + console.log(' 1. Open Firefox'); + console.log(' 2. Go to about:debugging#/runtime/this-firefox'); + console.log(' 3. Click "Load Temporary Add-on"'); + console.log(' 4. Select manifest.json from this directory'); + + } else { + // For Chrome build + console.log('Configuring for Chrome...'); + + // Restore Chrome manifest if backup exists + if (fs.existsSync(manifestBackupPath)) { + console.log('Restoring Chrome manifest...'); + fs.copyFileSync(manifestBackupPath, manifestPath); + console.log('βœ“ Chrome build configured'); + } else { + console.log('βœ“ Already using Chrome manifest'); + } + + console.log(''); + console.log('Note: To restore to the original manifest from version control,'); + console.log(' use: git checkout manifest.json'); + console.log(''); + console.log('Test the extension:'); + console.log(' 1. Open Chrome/Edge'); + console.log(' 2. Go to chrome://extensions or edge://extensions'); + console.log(' 3. Enable Developer mode'); + console.log(' 4. Click "Load unpacked"'); + console.log(' 5. Select this directory'); + } + + console.log(''); + console.log('Note: The extension uses scripts/browser-polyfill.js to handle'); + console.log(' API differences between Chrome and Firefox automatically.'); + +} catch (error) { + console.error('Error during build:', error); + process.exit(1); +} diff --git a/scripts/content.js b/scripts/content.js index 0106735f..85b3218d 100644 --- a/scripts/content.js +++ b/scripts/content.js @@ -29,20 +29,26 @@ if (window.checkExtensionLoaded) { let lastPageSourceScanTime = 0; // When the page source was captured let developerConsoleLoggingEnabled = false; // Cache for developer console logging setting let showingBanner = false; // Flag to prevent DOM monitoring loops when showing banners + let escalatedToBlock = false; // Flag to indicate page has been escalated to block - stop all monitoring const MAX_SCANS = 5; // Prevent infinite scanning - reduced for performance const SCAN_COOLDOWN = 1200; // 1200ms between scans - increased for performance + const THREAT_TRIGGERED_COOLDOWN = 500; // Shorter cooldown for threat-triggered re-scans const WARNING_THRESHOLD = 3; // Block if 4+ warning threats found (escalation threshold) - let initialBody; // Reference to the initial body element - + const PHISHING_PROCESSING_TIMEOUT = 10000; // 10 second timeout for phishing indicator processing + let forceMainThreadPhishingProcessing = false; // Toggle for debugging main thread only + const SLOW_PAGE_RESCAN_SKIP_THRESHOLD = 5000; // Don't re-scan if initial scan took > 5s + let lastProcessingTime = 0; // Track last phishing indicator processing time + let lastPageSourceHash = null; // Hash of page source to detect real changes + let threatTriggeredRescanCount = 0; // Track threat-triggered re-scans + const MAX_THREAT_TRIGGERED_RESCANS = 2; // Max follow-up scans when threats detected + let scheduledRescanTimeout = null; // Track scheduled re-scan timeout + const injectedElements = new Set(); // Global tracking for extension-injected elements const regexCache = new Map(); let cachedPageSource = null; let cachedPageSourceTime = 0; const PAGE_SOURCE_CACHE_TTL = 1000; - const domQueryCache = new WeakMap(); - let cachedStylesheetAnalysis = null; - - // Console log capturing - let capturedLogs = []; + let capturedLogs = []; // Console log capturing + let backgroundProcessingActive = false; // Prevent multiple background processing cycles const MAX_LOGS = 100; // Limit the number of stored logs // Override console methods to capture logs @@ -153,39 +159,272 @@ if (window.checkExtensionLoaded) { return cachedPageSource; } - function clearPerformanceCaches() { - cachedPageSource = null; - cachedPageSourceTime = 0; - domQueryCache.delete(document); - cachedStylesheetAnalysis = null; + /** + * Compute reliable hash of page source to detect changes + * Uses djb2 with intelligent sampling for performance + accuracy balance + */ + function computePageSourceHash(pageSource) { + if (!pageSource) return null; + + let hash = 5381; + const len = pageSource.length; + + // Sample ~1000 chars evenly distributed + const step = Math.max(1, Math.floor(len / 1000)); + + for (let i = 0; i < len; i += step) { + hash = ((hash << 5) + hash) + pageSource.charCodeAt(i); // hash * 33 + c + } + + // Include length for quick size-change detection + return `${len}:${hash >>> 0}`; + } + + /** + * Check if page source has changed significantly + */ + function hasPageSourceChanged() { + const currentSource = document.documentElement.outerHTML; // Direct access to bypass cache + const currentHash = computePageSourceHash(currentSource); + + if (!lastPageSourceHash) { + lastPageSourceHash = currentHash; + return false; // First check, no previous hash to compare + } + + const changed = currentHash !== lastPageSourceHash; + if (changed) { + logger.debug(`Page source changed: ${lastPageSourceHash} -> ${currentHash}`); + lastPageSourceHash = currentHash; + } + + return changed; + } + + /** + * Schedule threat-triggered re-scans with progressive delays + * Automatically re-scans when threats detected to catch late-loading content + */ + function scheduleThreatTriggeredRescan(threatCount) { + // Clear any existing scheduled re-scan + if (scheduledRescanTimeout) { + clearTimeout(scheduledRescanTimeout); + scheduledRescanTimeout = null; + } + + // Don't schedule if we've reached the limit + if (threatTriggeredRescanCount >= MAX_THREAT_TRIGGERED_RESCANS) { + logger.debug(`Max threat-triggered re-scans (${MAX_THREAT_TRIGGERED_RESCANS}) reached`); + return; + } + + // CRITICAL: Skip re-scan if initial scan was very slow (likely legitimate complex page) + if (lastProcessingTime > SLOW_PAGE_RESCAN_SKIP_THRESHOLD) { + logger.log( + `⏭️ Skipping threat-triggered re-scan - initial scan took ${lastProcessingTime}ms ` + + `(threshold: ${SLOW_PAGE_RESCAN_SKIP_THRESHOLD}ms). This is likely a legitimate complex application.` + ); + return; + } + + // Progressive delays: 800ms for first re-scan, 2000ms for second + const delays = [800, 2000]; + const delay = delays[threatTriggeredRescanCount] || 2000; + + logger.log( + `⏱️ Scheduling threat-triggered re-scan #${threatTriggeredRescanCount + 1} in ${delay}ms (${threatCount} threat(s) detected)` + ); + + threatTriggeredRescanCount++; + + scheduledRescanTimeout = setTimeout(() => { + logger.log(`πŸ”„ Running threat-triggered re-scan #${threatTriggeredRescanCount}`); + runProtection(true); + scheduledRescanTimeout = null; + }, delay); + } + + /** + * Register an element as injected by the extension + * MUST be called immediately after creating any DOM element + */ + function registerInjectedElement(element) { + if (element && element.nodeType === Node.ELEMENT_NODE) { + injectedElements.add(element); + logger.debug(`Registered injected element: ${element.tagName}#${element.id || 'no-id'}`); + } } - function analyzeStylesheets() { - if (cachedStylesheetAnalysis) return cachedStylesheetAnalysis; - const analysis = { hasMicrosoftCSS: false, cssContent: "", sheets: [] }; + /** + * Get clean page source with all extension elements removed + * This is secure because it uses object references, not selectors + */ + function getCleanPageSource() { try { - const styleSheets = Array.from(document.styleSheets); - for (const sheet of styleSheets) { - const sheetInfo = { href: sheet.href || "inline" }; - if (sheet.href?.match(/msauth|msft|microsoft/i)) { - analysis.hasMicrosoftCSS = true; + // Fast path: if no injected elements, skip cloning + if (injectedElements.size === 0) { + return document.documentElement.outerHTML; + } + + // Clone the entire document + const docClone = document.documentElement.cloneNode(true); + + // Build a map of original nodes to cloned nodes + const nodeMap = new Map(); + const buildNodeMap = (original, clone) => { + nodeMap.set(original, clone); + const originalChildren = Array.from(original.children || []); + const clonedChildren = Array.from(clone.children || []); + + for (let i = 0; i < originalChildren.length; i++) { + if (clonedChildren[i]) { + buildNodeMap(originalChildren[i], clonedChildren[i]); + } } + }; + + try { + buildNodeMap(document.documentElement, docClone); + } catch (buildMapError) { + logger.warn("Error building node map (likely SVG parsing issue), using fallback:", buildMapError.message); + // Fallback: return original HTML (extension elements will be included but it's better than crashing) + return document.documentElement.outerHTML; + } + + // Remove cloned versions of our injected elements + let removed = 0; + injectedElements.forEach(originalElement => { try { - if (sheet.cssRules) { - analysis.cssContent += - Array.from(sheet.cssRules) - .map((r) => r.cssText) - .join(" ") + " "; - sheetInfo.accessible = true; + const clonedElement = nodeMap.get(originalElement); + if (clonedElement && clonedElement.parentNode) { + clonedElement.parentNode.removeChild(clonedElement); + removed++; } - } catch (e) { - sheetInfo.accessible = false; + } catch (removeError) { + // Skip elements that can't be removed + logger.debug(`Could not remove element from clone: ${removeError.message}`); } - analysis.sheets.push(sheetInfo); + }); + + logger.debug(`Removed ${removed} extension elements from scan`); + + try { + return docClone.outerHTML; + } catch (serializeError) { + logger.warn("Error serializing cleaned DOM (SVG issue), using original:", serializeError.message); + return document.documentElement.outerHTML; } - } catch (e) {} - cachedStylesheetAnalysis = analysis; - return analysis; + } catch (error) { + logger.error("Failed to get clean page source:", error.message); + // Ultimate fallback: return original HTML + return document.documentElement.outerHTML; + } + } + + /** + * Get clean page text with extension elements removed + */ + function getCleanPageText() { + try { + // Fast path: if no injected elements, skip cloning + if (injectedElements.size === 0) { + return document.body?.textContent || ''; + } + + // Create temporary container + const tempDiv = document.createElement('div'); + tempDiv.style.display = 'none'; + document.body.appendChild(tempDiv); + + try { + // Clone body + const bodyClone = document.body.cloneNode(true); + tempDiv.appendChild(bodyClone); + + // Remove our injected elements from the clone + injectedElements.forEach(originalElement => { + if (originalElement.isConnected) { + try { + // Find equivalent element in clone by traversing same path + const path = getElementPath(originalElement); + const clonedElement = getElementByPath(bodyClone, path); + if (clonedElement && clonedElement.parentNode) { + clonedElement.parentNode.removeChild(clonedElement); + } + } catch (pathError) { + // Skip elements that can't be found in clone + logger.debug(`Could not find element in clone: ${pathError.message}`); + } + } + }); + + return bodyClone.textContent || ''; + } catch (cloneError) { + logger.warn("Error cloning body for text extraction (SVG issue), using original:", cloneError.message); + return document.body?.textContent || ''; + } finally { + document.body.removeChild(tempDiv); + } + } catch (error) { + logger.error("Failed to get clean page text:", error.message); + // Ultimate fallback: return original text + return document.body?.textContent || ''; + } + } + + /** + * Get path to element from root (for finding clone) + */ + function getElementPath(element) { + const path = []; + let current = element; + + while (current && current !== document.body) { + const parent = current.parentNode; + if (parent) { + const siblings = Array.from(parent.children); + path.unshift(siblings.indexOf(current)); + } + current = parent; + } + + return path; + } + + /** + * Get element by path in a cloned tree + */ + function getElementByPath(root, path) { + let current = root; + + for (const index of path) { + if (!current.children || !current.children[index]) { + return null; + } + current = current.children[index]; + } + + return current; + } + + /** + * Cleanup removed elements from tracking + */ + function cleanupInjectedElements() { + const toRemove = []; + + injectedElements.forEach(element => { + // If element no longer in DOM, remove from tracking + if (!element.isConnected) { + toRemove.push(element); + } + }); + + toRemove.forEach(element => injectedElements.delete(element)); + + if (toRemove.length > 0) { + logger.debug(`Cleaned up ${toRemove.length} disconnected elements from tracking`); + } } /** @@ -238,6 +477,58 @@ if (window.checkExtensionLoaded) { } } + /** + * Consolidated domain trust check - single URL parse for all pattern types + * Optimization: Parses URL once and checks all pattern categories + * @param {string} url - The URL to check + * @returns {Object} Trust status for all categories: { isTrustedLogin, isMicrosoft, isExcluded } + */ + function checkDomainTrust(url) { + try { + const urlObj = new URL(url); + const origin = urlObj.origin; + + return { + isTrustedLogin: matchesAnyPattern(origin, trustedLoginPatterns), + isMicrosoft: matchesAnyPattern(origin, microsoftDomainPatterns), + isExcluded: checkDomainExclusionByOrigin(origin) + }; + } catch (error) { + logger.warn("Invalid URL for domain trust check:", url); + return { + isTrustedLogin: false, + isMicrosoft: false, + isExcluded: false + }; + } + } + + /** + * Check if origin is in exclusion system (helper for checkDomainTrust) + * @param {string} origin - The origin to check + * @returns {boolean} - True if origin is excluded + */ + function checkDomainExclusionByOrigin(origin) { + if (detectionRules?.exclusion_system?.domain_patterns) { + const rulesExcluded = + detectionRules.exclusion_system.domain_patterns.some((pattern) => { + try { + const regex = getCachedRegex(pattern, "i"); + return regex.test(origin); + } catch (error) { + logger.warn(`Invalid exclusion pattern: ${pattern}`); + return false; + } + }); + + if (rulesExcluded) { + logger.log(`βœ… URL excluded by detection rules: ${origin}`); + return true; + } + } + return checkUserUrlAllowlist(origin); + } + // Conditional logger that respects developer console logging setting const logger = { log: (...args) => { @@ -274,6 +565,9 @@ if (window.checkExtensionLoaded) { developerConsoleLoggingEnabled = config.enableDeveloperConsoleLogging === true; // "Developer Mode" in UI + // Also load forceMainThreadPhishingProcessing + forceMainThreadPhishingProcessing = config.forceMainThreadPhishingProcessing === true; + // Only setup console capture if developer mode is enabled if (developerConsoleLoggingEnabled) { setupConsoleCapture(); @@ -289,20 +583,6 @@ if (window.checkExtensionLoaded) { } } - /** - * Re-initialize the DOM observer. This is critical for pages that use - * document.write() to replace the entire DOM after initial load. - */ - function reinitializeObserver() { - logger.warn("DOM appears to have been replaced. Re-initializing observer."); - if (domObserver) { - domObserver.disconnect(); - domObserver = null; - } - clearPerformanceCaches(); - setupDomObserver(); - } - /** * Load detection rules from the rule file - EVERYTHING comes from here * Now uses the detection rules manager for caching and remote loading @@ -730,7 +1010,6 @@ if (window.checkExtensionLoaded) { }, consoleLogs: capturedLogs.slice(), // Copy the captured logs pageSource: lastScannedPageSource || document.documentElement.outerHTML, - timestamp: Date.now(), }; console.log( @@ -738,19 +1017,46 @@ if (window.checkExtensionLoaded) { ); // Store in chrome storage with URL-based key + // Wrap in same structure as popup expects const storageKey = `debug_data_${btoa(originalUrl).substring(0, 50)}`; - await new Promise((resolve, reject) => { - chrome.storage.local.set({ [storageKey]: debugData }, () => { + const dataToStore = { + url: originalUrl, + timestamp: Date.now(), + debugData: debugData, + }; + + // Use Promise.race with 100ms timeout to avoid blocking phishing page redirect + // This ensures user protection is prioritized while still attempting to store debug data + const storagePromise = new Promise((resolve, reject) => { + chrome.storage.local.set({ [storageKey]: dataToStore }, () => { if (chrome.runtime.lastError) { + console.error("Storage error:", chrome.runtime.lastError.message); reject(chrome.runtime.lastError); } else { - console.log("Debug data stored before redirect:", storageKey); - resolve(); + console.log("Debug data stored successfully:", storageKey); + resolve(true); } }); }); + + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + console.warn("Debug data storage timeout (100ms) - proceeding with block for user safety"); + resolve(false); + }, 100); + }); + + const completed = await Promise.race([storagePromise, timeoutPromise]); + + // If timeout was reached, continue storage in background (fire-and-forget) + if (completed === false) { + storagePromise.catch((err) => { + console.error("Background storage failed:", err?.message || String(err)); + }); + } } catch (error) { - console.error("Failed to store debug data before redirect:", error); + console.error("Failed to store debug data before redirect:", error?.message || String(error)); + // Continue with redirect even if storage fails - user protection is priority } } @@ -834,7 +1140,8 @@ if (window.checkExtensionLoaded) { console.log("Is Trusted Domain:", isTrusted); // Check M365 detection - const isMSLogon = isMicrosoftLogonPage(); + const msDetection = detectMicrosoftElements(); + const isMSLogon = msDetection.isLogonPage; console.log("Detected as M365 Login:", isMSLogon); // Run phishing indicators @@ -921,144 +1228,47 @@ if (window.checkExtensionLoaded) { }; /** - * Check if page has ANY Microsoft-related elements (lower threshold than full detection) - * Used to determine if phishing indicators should be checked + * Unified Microsoft element detection with rich results + * Optimization: Single scan that calculates both logon page and element presence + * @returns {Object} Detection results: { isLogonPage, hasElements, primaryFound, totalWeight, totalElements, foundElements } */ - function hasMicrosoftElements() { + function detectMicrosoftElements() { try { + // Check domain exclusion first const isExcludedDomain = checkDomainExclusion(window.location.href); if (isExcludedDomain) { logger.log( `βœ… Domain excluded from scanning - skipping Microsoft elements check: ${window.location.href}` ); - return false; // Skip phishing indicators for excluded domains - } - - if (!detectionRules?.m365_detection_requirements) { - return false; - } - - const requirements = detectionRules.m365_detection_requirements; - const pageSource = getPageSource(); - const pageText = document.body?.textContent || ""; - - // Lower threshold - just need ANY Microsoft-related elements - let totalWeight = 0; - let totalElements = 0; - - const allElements = [ - ...(requirements.primary_elements || []), - ...(requirements.secondary_elements || []), - ]; - - for (const element of allElements) { - try { - let found = false; - - if (element.type === "source_content") { - const regex = new RegExp(element.pattern, "i"); - found = regex.test(pageSource); - } else if (element.type === "css_pattern") { - found = element.patterns.some((pattern) => { - const regex = new RegExp(pattern, "i"); - return regex.test(pageSource); - }); - } else if (element.type === "url_pattern") { - found = element.patterns.some((pattern) => { - const regex = new RegExp(pattern, "i"); - return regex.test(window.location.href); - }); - } else if (element.type === "text_content") { - found = element.patterns.some((pattern) => { - const regex = new RegExp(pattern, "i"); - return regex.test(pageText); - }); - } - - if (found) { - totalWeight += element.weight || 1; - totalElements++; - } - } catch (error) { - logger.warn(`Error checking element ${element.category}:`, error); - } - } - - // Tightened threshold - require either: - // 1. At least one primary element (Microsoft-specific), OR - // 2. High weight secondary elements (weight >= 4), OR - // 3. Multiple secondary elements (3+) with decent weight (>= 3) - const primaryElements = allElements.filter( - (el) => el.category === "primary" - ); - const foundPrimaryElements = []; - - // Check if any primary elements were found - for (const element of primaryElements) { - try { - let found = false; - - if (element.type === "source_content") { - const regex = new RegExp(element.pattern, "i"); - found = regex.test(pageSource); - } else if (element.type === "css_pattern") { - found = element.patterns.some((pattern) => { - const regex = new RegExp(pattern, "i"); - return regex.test(pageSource); - }); - } - - if (found) { - foundPrimaryElements.push(element.id); - } - } catch (error) { - // Skip invalid patterns - } - } - - const hasElements = - foundPrimaryElements.length > 0 || - totalWeight >= 4 || - (totalElements >= 3 && totalWeight >= 3); - - if (hasElements) { - if (foundPrimaryElements.length > 0) { - logger.log( - `πŸ” Microsoft-specific elements detected (Primary: ${foundPrimaryElements.join( - ", " - )}) - will check phishing indicators` - ); - } else { - logger.log( - `πŸ” High-confidence Microsoft elements detected (Weight: ${totalWeight}, Elements: ${totalElements}) - will check phishing indicators` - ); - } - } else { - logger.log( - `πŸ“„ Insufficient Microsoft indicators (Weight: ${totalWeight}, Elements: ${totalElements}, Primary: ${foundPrimaryElements.length}) - skipping phishing indicators for performance` - ); + return { + isLogonPage: false, + hasElements: false, + primaryFound: 0, + totalWeight: 0, + totalElements: 0, + foundElements: [], + pageSource: null + }; } - return hasElements; - } catch (error) { - logger.error("Error in hasMicrosoftElements:", error.message); - return true; // Default to checking on error to be safe - } - } - - /** - * Check if page is Microsoft 365 logon page using categorized detection - * Requirements: Primary elements are Microsoft-specific, secondary are supporting evidence - */ - function isMicrosoftLogonPage() { - try { if (!detectionRules?.m365_detection_requirements) { logger.error("No M365 detection requirements in rules"); - return false; + return { + isLogonPage: false, + hasElements: false, + primaryFound: 0, + totalWeight: 0, + totalElements: 0, + foundElements: [], + pageSource: null + }; } const requirements = detectionRules.m365_detection_requirements; const pageSource = getPageSource(); + const pageText = document.body?.textContent || ""; + const pageTitle = document.title || ""; + const metaTags = Array.from(document.querySelectorAll('meta')); // Store the page source for debugging purposes lastScannedPageSource = pageSource; @@ -1070,12 +1280,12 @@ if (window.checkExtensionLoaded) { const foundElementsList = []; const missingElementsList = []; - // Check primary elements (Microsoft-specific) const allElements = [ ...(requirements.primary_elements || []), ...(requirements.secondary_elements || []), ]; + // Single loop - check all elements once for (const element of allElements) { try { let found = false; @@ -1083,8 +1293,48 @@ if (window.checkExtensionLoaded) { if (element.type === "source_content") { const regex = new RegExp(element.pattern, "i"); found = regex.test(pageSource); + } else if (element.type === "page_title") { + found = element.patterns.some((pattern) => { + const regex = new RegExp(pattern, "i"); + return regex.test(pageTitle); + }); + + if (found) { + logger.debug(`βœ“ Page title matched: "${pageTitle}"`); + } + } else if (element.type === "meta_tag") { + const metaAttr = element.attribute; + + found = metaTags.some(meta => { + let content = ""; + + if (metaAttr === "description") { + content = meta.getAttribute("name") === "description" + ? meta.getAttribute("content") || "" + : ""; + } else if (metaAttr.startsWith("og:")) { + content = meta.getAttribute("property") === metaAttr + ? meta.getAttribute("content") || "" + : ""; + } else { + content = meta.getAttribute("name") === metaAttr + ? meta.getAttribute("content") || "" + : ""; + } + + if (content) { + return element.patterns.some(pattern => { + const regex = new RegExp(pattern, "i"); + return regex.test(content); + }); + } + return false; + }); + + if (found) { + logger.debug(`βœ“ Meta tag matched: ${metaAttr}`); + } } else if (element.type === "css_pattern") { - // Check for CSS patterns in the page source found = element.patterns.some((pattern) => { const regex = new RegExp(pattern, "i"); return regex.test(pageSource); @@ -1120,6 +1370,16 @@ if (window.checkExtensionLoaded) { ); } } + } else if (element.type === "url_pattern") { + found = element.patterns.some((pattern) => { + const regex = new RegExp(pattern, "i"); + return regex.test(window.location.href); + }); + } else if (element.type === "text_content") { + found = element.patterns.some((pattern) => { + const regex = new RegExp(pattern, "i"); + return regex.test(pageText); + }); } if (found) { @@ -1151,94 +1411,91 @@ if (window.checkExtensionLoaded) { } } - // New categorized detection logic with flexible thresholds + // Calculate thresholds for logon page detection (strict) const thresholds = requirements.detection_thresholds || {}; const minPrimary = thresholds.minimum_primary_elements || 1; const minWeight = thresholds.minimum_total_weight || 4; const minTotal = thresholds.minimum_elements_overall || 3; - const minSecondaryOnlyWeight = - thresholds.minimum_secondary_only_weight || 6; - const minSecondaryOnlyElements = - thresholds.minimum_secondary_only_elements || 5; + const minSecondaryOnlyWeight = thresholds.minimum_secondary_only_weight || 9; + const minSecondaryOnlyElements = thresholds.minimum_secondary_only_elements || 7; - let isM365Page = false; + let isLogonPage = false; if (primaryFound > 0) { - // If we have primary elements, use normal thresholds - isM365Page = + isLogonPage = primaryFound >= minPrimary && totalWeight >= minWeight && totalElements >= minTotal; } else { - // If NO primary elements, require higher secondary evidence - // This catches phishing simulations while preventing false positives like GitHub - isM365Page = + isLogonPage = totalWeight >= minSecondaryOnlyWeight && totalElements >= minSecondaryOnlyElements; } - if (primaryFound > 0) { - logger.log( - `M365 logon detection (with primary): Primary=${primaryFound}/${minPrimary}, Weight=${totalWeight}/${minWeight}, Total=${totalElements}/${minTotal}` - ); - } else { - logger.log( - `M365 logon detection (secondary only): Weight=${totalWeight}/${minSecondaryOnlyWeight}, Total=${totalElements}/${minSecondaryOnlyElements}` - ); - } - logger.log(`Found elements: [${foundElementsList.join(", ")}]`); - if (missingElementsList.length > 0) { - logger.log(`Missing elements: [${missingElementsList.join(", ")}]`); - } - - // Enhanced debugging - show what we're actually looking for - logger.debug("=== DETECTION DEBUG INFO ==="); - logger.debug(`Page URL: ${window.location.href}`); - logger.debug(`Page title: ${document.title}`); - logger.debug(`Page source length: ${pageSource.length} chars`); + // Calculate hasElements (looser threshold for element presence) + const hasElements = + primaryFound > 0 || + totalWeight >= 4 || + (totalElements >= 3 && totalWeight >= 3); - // Debug each pattern individually - for (const element of allElements) { - if (element.type === "source_content") { - const regex = new RegExp(element.pattern, "i"); - const matches = pageSource.match(regex); - logger.debug( - `${element.category} pattern "${element.pattern}" -> ${ - matches ? "FOUND" : "NOT FOUND" - }` + // Logging + if (isLogonPage) { + if (primaryFound > 0) { + logger.log( + `M365 logon detection (with primary): Primary=${primaryFound}/${minPrimary}, Weight=${totalWeight}/${minWeight}, Total=${totalElements}/${minTotal}` + ); + } else { + logger.log( + `M365 logon detection (secondary only): Weight=${totalWeight}/${minSecondaryOnlyWeight}, Total=${totalElements}/${minSecondaryOnlyElements}` ); - if (matches) logger.debug(` Match: "${matches[0]}"`); - } else if (element.type === "css_pattern") { - element.patterns.forEach((pattern, idx) => { - const regex = new RegExp(pattern, "i"); - const matches = pageSource.match(regex); - logger.debug( - `${element.category} CSS pattern[${idx}] "${pattern}" -> ${ - matches ? "FOUND" : "NOT FOUND" - }` - ); - if (matches) logger.debug(` Match: "${matches[0]}"`); - }); } - } - logger.debug("=== END DEBUG INFO ==="); - - const resultMessage = isM365Page - ? "βœ… DETECTED as Microsoft 365 logon page" - : "❌ NOT DETECTED as Microsoft 365 logon page"; - - logger.log(`🎯 Detection Result: ${resultMessage}`); - - if (isM365Page) { + logger.log(`Found elements: [${foundElementsList.join(", ")}]`); + if (missingElementsList.length > 0) { + logger.log(`Missing elements: [${missingElementsList.join(", ")}]`); + } + logger.log(`🎯 Detection Result: βœ… DETECTED as Microsoft 365 logon page`); logger.log( "πŸ“‹ Next step: Analyzing if this is legitimate or phishing attempt..." ); + } else if (hasElements) { + if (primaryFound > 0) { + logger.log( + `πŸ” Microsoft-specific elements detected (Primary: ${foundElementsList.filter(id => { + const elem = allElements.find(e => e.id === id); + return elem?.category === "primary"; + }).join(", ")}) - will check phishing indicators` + ); + } else { + logger.log( + `πŸ” High-confidence Microsoft elements detected (Weight: ${totalWeight}, Elements: ${totalElements}) - will check phishing indicators` + ); + } + } else { + logger.log( + `πŸ“„ Insufficient Microsoft indicators (Weight: ${totalWeight}, Elements: ${totalElements}, Primary: ${primaryFound}) - skipping phishing indicators for performance` + ); } - return isM365Page; + return { + isLogonPage, + hasElements, + primaryFound, + totalWeight, + totalElements, + foundElements: foundElementsList, + pageSource + }; } catch (error) { - logger.error("M365 logon page detection failed:", error.message); - return false; // Fail closed - don't assume it's MS page if detection fails + logger.error("Error in detectMicrosoftElements:", error.message); + return { + isLogonPage: false, + hasElements: true, // Fail open for element detection + primaryFound: 0, + totalWeight: 0, + totalElements: 0, + foundElements: [], + pageSource: null + }; } } @@ -1543,6 +1800,286 @@ if (window.checkExtensionLoaded) { ); } + /** + * Detection Primitives Engine + * Generic, reusable detection logic controlled 100% by rules file + */ + const DetectionPrimitives = { + /** + * Check if any of the values are present in source + */ + substring_present: (source, params) => { + const lower = source.toLowerCase(); + return params.values.some(val => lower.includes(val.toLowerCase())); + }, + + /** + * Check if ALL values are present in source + */ + all_substrings_present: (source, params) => { + const lower = source.toLowerCase(); + return params.values.every(val => lower.includes(val.toLowerCase())); + }, + + /** + * Check if two words are within max_distance characters of each other + */ + substring_proximity: (source, params) => { + const lower = source.toLowerCase(); + const word1 = params.word1.toLowerCase(); + const word2 = params.word2.toLowerCase(); + + const idx1 = lower.indexOf(word1); + if (idx1 === -1) return false; + + // Search in a window around word1 + const searchStart = Math.max(0, idx1 - params.max_distance); + const searchEnd = Math.min(lower.length, idx1 + word1.length + params.max_distance); + const chunk = lower.slice(searchStart, searchEnd); + + return chunk.includes(word2); + }, + + /** + * Check if minimum number of substrings are present + */ + substring_count: (source, params) => { + const lower = source.toLowerCase(); + const count = params.substrings.filter(sub => + lower.includes(sub.toLowerCase()) + ).length; + + return count >= params.min_count && + count <= (params.max_count || Infinity); + }, + + /** + * Check if required substrings are present but prohibited ones are not + */ + has_but_not: (source, params) => { + const lower = source.toLowerCase(); + + // Check if any required substring is present + const hasRequired = params.required.some(req => + lower.includes(req.toLowerCase()) + ); + + if (!hasRequired) return false; + + // Check if any prohibited substring is present + const hasProhibited = params.prohibited.some(pro => + lower.includes(pro.toLowerCase()) + ); + + return !hasProhibited; + }, + + /** + * Check if patterns match within allowed count range + */ + pattern_count: (source, params) => { + let totalCount = 0; + + for (const pattern of params.patterns) { + const regex = new RegExp(pattern, params.flags || 'gi'); + const matches = source.match(regex); + totalCount += matches ? matches.length : 0; + } + + return totalCount >= params.min_count && + totalCount <= (params.max_count || Infinity); + }, + + /** + * Check word density (occurrences per 1000 characters) + */ + word_density: (source, params) => { + const lower = source.toLowerCase(); + let totalCount = 0; + + for (const word of params.words) { + const regex = new RegExp(`\\b${word.toLowerCase()}\\b`, 'g'); + const matches = lower.match(regex); + totalCount += matches ? matches.length : 0; + } + + const density = totalCount / (source.length / 1000); + return density >= params.min_density; + }, + + /** + * Check if substring appears before another + */ + substring_before: (source, params) => { + const lower = source.toLowerCase(); + const idx1 = lower.indexOf(params.first.toLowerCase()); + const idx2 = lower.indexOf(params.second.toLowerCase()); + + return idx1 !== -1 && idx2 !== -1 && idx1 < idx2; + }, + + /** + * Check if substring is within position range + */ + substring_in_range: (source, params) => { + const lower = source.toLowerCase(); + const idx = lower.indexOf(params.substring.toLowerCase()); + + if (idx === -1) return false; + + return idx >= (params.min_position || 0) && + idx <= (params.max_position || Infinity); + }, + + /** + * Composite: ALL operations must match + */ + all_of: (source, params, context) => { + return params.operations.every(op => + evaluatePrimitive(source, op, context) + ); + }, + + /** + * Composite: ANY operation must match + */ + any_of: (source, params, context) => { + return params.operations.some(op => + evaluatePrimitive(source, op, context) + ); + }, + + /** + * Check if resource URLs match pattern + */ + resource_pattern: (source, params) => { + const pattern = new RegExp(params.pattern, params.flags || 'i'); + + // Extract URLs from common attributes + const urlRegex = /(?:src|href|action)=["']([^"']+)["']/gi; + const urls = [...source.matchAll(urlRegex)].map(m => m[1]); + + const matchCount = urls.filter(url => pattern.test(url)).length; + + return matchCount >= (params.min_count || 1) && + matchCount <= (params.max_count || Infinity); + }, + + /** + * Check if resources come from allowed domains + */ + resource_from_domain: (source, params) => { + const resourceType = params.resource_type; + const allowedDomains = params.allowed_domains; + + // Find all resources of this type + const resourceRegex = new RegExp( + `(?:src|href)=["']([^"']*${resourceType}[^"']*)["']`, + 'gi' + ); + const resources = [...source.matchAll(resourceRegex)].map(m => m[1]); + + if (resources.length === 0) return false; + + // Check if ALL resources are from allowed domains + return resources.every(res => + allowedDomains.some(domain => res.includes(domain)) + ); + }, + + /** + * Check multiple proximity pairs + */ + multi_proximity: (source, params) => { + const lower = source.toLowerCase(); + + for (const pair of params.pairs) { + const word1 = pair.words[0].toLowerCase(); + const word2 = pair.words[1].toLowerCase(); + const maxDist = pair.max_distance; + + let idx1 = -1; + while ((idx1 = lower.indexOf(word1, idx1 + 1)) !== -1) { + const searchStart = Math.max(0, idx1 - maxDist); + const searchEnd = Math.min(lower.length, idx1 + word1.length + maxDist); + const chunk = lower.slice(searchStart, searchEnd); + + if (chunk.includes(word2)) { + return true; // Found one matching pair + } + } + } + + return false; + }, + + /** + * Check if form action doesn't contain required domains + */ + form_action_check: (source, params) => { + const formRegex = /]*action=["']([^"']*)["'][^>]*>/gi; + const actions = [...source.matchAll(formRegex)].map(m => m[1]); + + if (actions.length === 0) return false; + + const requiredDomains = params.required_domains; + const suspiciousForms = actions.filter(action => + !requiredDomains.some(domain => action.includes(domain)) + ); + + return suspiciousForms.length > 0; + }, + + /** + * Check obfuscation patterns + */ + obfuscation_check: (source, params) => { + const indicators = params.indicators; + let matchCount = 0; + + for (const indicator of indicators) { + if (source.includes(indicator)) { + matchCount++; + } + } + + return matchCount >= params.min_matches; + } + }; + + /** + * Evaluate a single primitive operation + */ + function evaluatePrimitive(source, operation, context = {}) { + const primitive = DetectionPrimitives[operation.type]; + + if (!primitive) { + logger.warn(`Unknown primitive type: ${operation.type}`); + return false; + } + + try { + // Check cache first + const cacheKey = `${operation.type}:${JSON.stringify(operation)}`; + if (context.cache && context.cache.has(cacheKey)) { + return context.cache.get(cacheKey); + } + + const result = primitive(source, operation, context); + const finalResult = operation.invert ? !result : result; + + // Cache result + if (context.cache) { + context.cache.set(cacheKey, finalResult); + } + + return finalResult; + } catch (error) { + logger.error(`Primitive ${operation.type} failed:`, error.message); + return false; + } + } + /** * Process phishing indicators using Web Worker for background processing */ @@ -1705,23 +2242,29 @@ if (window.checkExtensionLoaded) { * Process phishing indicators from detection rules */ async function processPhishingIndicators() { + const startTime = Date.now(); // Track processing time try { const currentUrl = window.location.href; - // Debug logging logger.log( `πŸ” processPhishingIndicators: detectionRules available: ${!!detectionRules}` ); if (!detectionRules?.phishing_indicators) { logger.warn("No phishing indicators available"); + lastProcessingTime = Date.now() - startTime; // Track even for early exit return { threats: [], score: 0 }; } const threats = []; let totalScore = 0; - const pageSource = getPageSource(); - const pageText = document.body?.textContent || ""; + + // CRITICAL FIX: Use clean page source with extension elements removed + const pageSource = injectedElements.size > 0 ? getCleanPageSource() : getPageSource(); + const pageText = injectedElements.size > 0 ? getCleanPageText() : (document.body?.textContent || ""); + + // Cleanup disconnected elements before processing + cleanupInjectedElements(); logger.log( `πŸ” Testing ${detectionRules.phishing_indicators.length} phishing indicators against:` @@ -1729,11 +2272,11 @@ if (window.checkExtensionLoaded) { logger.log(` - Page source length: ${pageSource.length} chars`); logger.log(` - Page text length: ${pageText.length} chars`); logger.log(` - Current URL: ${currentUrl}`); + logger.log(` - Injected elements excluded: ${injectedElements.size}`); // Check for legitimate context indicators const legitimateContext = checkLegitimateContext(pageText, pageSource); - // For pages with legitimate context, log but continue with detection if (legitimateContext) { logger.log( `πŸ“‹ Legitimate context detected - continuing with phishing detection` @@ -1747,83 +2290,111 @@ if (window.checkExtensionLoaded) { logger.log(` ${i + 1}. ${ind.id}: ${ind.pattern} (${ind.severity})`); }); - // Performance protection: Add timeout mechanism - const startTime = Date.now(); - const PROCESSING_TIMEOUT = 5000; // Standard timeout - let processedCount = 0; + // If forceMainThreadPhishingProcessing is enabled, skip Web Worker and use main thread directly + if (forceMainThreadPhishingProcessing) { + logger.log("⏱️ DEBUG: Forcing main thread phishing processing (Web Worker disabled by UI toggle)"); + } else { + // Try Web Worker for background processing first with timeout protection + logger.log(`⏱️ PERF: Attempting background processing with Web Worker`); + try { + const timeoutMs = PHISHING_PROCESSING_TIMEOUT; + const backgroundPromise = processPhishingIndicatorsInBackground( + detectionRules.phishing_indicators, + pageSource, + pageText, + currentUrl + ); + const resultPromise = timeoutMs + ? Promise.race([ + backgroundPromise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Web Worker timeout')), + timeoutMs + ) + ), + ]) + : backgroundPromise; + + const backgroundResult = await resultPromise; - // Try Web Worker for background processing first - logger.log(`⏱️ PERF: Attempting background processing with Web Worker`); - try { - const backgroundResult = await processPhishingIndicatorsInBackground( - detectionRules.phishing_indicators, - pageSource, - pageText, - currentUrl - ); + if ( + backgroundResult && + (backgroundResult.threats.length > 0 || backgroundResult.score >= 0) + ) { + const processingTime = Date.now() - startTime; + lastProcessingTime = processingTime; // CRITICAL: Track time - if ( - backgroundResult && - (backgroundResult.threats.length > 0 || backgroundResult.score >= 0) - ) { - logger.log(`⏱️ PERF: Background processing completed successfully`); + logger.log( + `⏱️ PERF: Background processing completed successfully in ${processingTime}ms` + ); - // Apply context filtering and SSO checks to background results - const filteredThreats = []; - for (const threat of backgroundResult.threats) { - let includeThread = true; + // Apply context filtering and SSO checks to background results + const filteredThreats = []; + for (const threat of backgroundResult.threats) { + let includeThread = true; - // Apply context_required filtering from rules - const indicator = detectionRules.phishing_indicators.find( - (ind) => ind.id === threat.id - ); - if (indicator?.context_required) { - let contextFound = false; - for (const requiredContext of indicator.context_required) { - if ( - pageSource - .toLowerCase() - .includes(requiredContext.toLowerCase()) || - pageText.toLowerCase().includes(requiredContext.toLowerCase()) - ) { - contextFound = true; - break; + const indicator = detectionRules.phishing_indicators.find( + (ind) => ind.id === threat.id + ); + if (indicator?.context_required) { + let contextFound = false; + for (const requiredContext of indicator.context_required) { + if ( + pageSource + .toLowerCase() + .includes(requiredContext.toLowerCase()) || + pageText.toLowerCase().includes(requiredContext.toLowerCase()) + ) { + contextFound = true; + break; + } + } + if (!contextFound) { + includeThread = false; + logger.debug( + `🚫 ${threat.id} excluded - required context not found` + ); + } + } + + if ( + includeThread && + (threat.id === "phi_001_enhanced" || threat.id === "phi_002") + ) { + const hasLegitimateSSO = checkLegitimateSSO(pageText, pageSource); + if (hasLegitimateSSO) { + includeThread = false; + logger.debug( + `🚫 ${threat.id} excluded - legitimate SSO detected` + ); } } - if (!contextFound) { - includeThread = false; - logger.debug( - `🚫 ${threat.id} excluded - required context not found` - ); - } - } - // Apply SSO exclusion from rules - if ( - includeThread && - (threat.id === "phi_001_enhanced" || threat.id === "phi_002") - ) { - const hasLegitimateSSO = checkLegitimateSSO(pageText, pageSource); - if (hasLegitimateSSO) { - includeThread = false; - logger.debug( - `🚫 ${threat.id} excluded - legitimate SSO detected` - ); + if (includeThread) { + filteredThreats.push(threat); } } - if (includeThread) { - filteredThreats.push(threat); - } - } + logger.log( + `⏱️ Phishing indicators check (Web Worker): ${filteredThreats.length} threats found, ` + + `score: ${backgroundResult.score}, processing time: ${processingTime}ms` + ); - return { threats: filteredThreats, score: backgroundResult.score }; + // Log per-indicator processing time if available (Web Worker cannot measure per-indicator, so log total only) + // If you want per-indicator, use main thread fallback below. + + return { threats: filteredThreats, score: backgroundResult.score }; + } + } catch (workerError) { + const failureTime = Date.now() - startTime; + // CRITICAL FIX: Track time even on Web Worker failure before falling back + lastProcessingTime = failureTime; + logger.warn( + `Web Worker processing failed after ${failureTime}ms, falling back to main thread:`, + workerError.message + ); } - } catch (workerError) { - logger.warn( - "Web Worker processing failed, falling back to main thread:", - workerError.message - ); } // Fallback to main thread processing with requestIdleCallback optimization @@ -1834,6 +2405,7 @@ if (window.checkExtensionLoaded) { const threats = []; let totalScore = 0; let processedCount = 0; + const mainThreadStartTime = Date.now(); const processNextBatch = async () => { const BATCH_SIZE = 2; // Smaller batches for idle processing @@ -1843,45 +2415,150 @@ if (window.checkExtensionLoaded) { detectionRules.phishing_indicators.length ); + for (let i = startIdx; i < endIdx; i++) { const indicator = detectionRules.phishing_indicators[i]; processedCount++; - + const indicatorStart = performance.now(); try { let matches = false; let matchDetails = ""; - const pattern = new RegExp( - indicator.pattern, - indicator.flags || "i" - ); + // Modular code-driven logic if flagged in rules file + if (indicator.code_driven === true && indicator.code_logic) { + if (DetectionPrimitives[indicator.code_logic.type]) { + try { + matches = evaluatePrimitive(pageSource, indicator.code_logic, { cache: new Map() }); + if (matches) matchDetails = "primitive match"; + } catch (primitiveError) { + logger.warn(`Primitive evaluation failed for ${indicator.id}, falling back:`, primitiveError.message); + // Fall through to legacy code-driven logic below + } + } + if (indicator.code_logic.type === "substring") { + // All substrings must be present + matches = (indicator.code_logic.substrings || []).every(sub => pageSource.includes(sub)); + if (matches) matchDetails = "page source (substring match)"; + } else if (indicator.code_logic.type === "substring_not") { + // All substrings must be present, and all not_substrings must be absent + matches = (indicator.code_logic.substrings || []).every(sub => pageSource.includes(sub)) && + (indicator.code_logic.not_substrings || []).every(sub => !pageSource.includes(sub)); + if (matches) matchDetails = "page source (substring + not match)"; + } else if (indicator.code_logic.type === "allowlist") { + // If any allowlist phrase is present, skip + const lowerSource = pageSource.toLowerCase(); + const isAllowlisted = (indicator.code_logic.allowlist || []).some(phrase => lowerSource.includes(phrase)); + if (!isAllowlisted) { + // Use optimized regex from rules file + if (indicator.code_logic.optimized_pattern) { + const optPattern = new RegExp(indicator.code_logic.optimized_pattern, indicator.flags || "i"); + if (optPattern.test(pageSource)) { + matches = true; + matchDetails = "page source (optimized regex)"; + } + } + } + } else if (indicator.code_logic.type === "substring_not_allowlist") { + // Check if substring is present, then verify it's not from an allowed source + const substring = indicator.code_logic.substring; + const allowlist = indicator.code_logic.allowlist || []; + + if (substring && pageSource.includes(substring)) { + // Substring found, now check if any allowlisted domain is also present + const lowerSource = pageSource.toLowerCase(); + const isAllowed = allowlist.some(allowed => + lowerSource.includes(allowed.toLowerCase()) + ); + + if (!isAllowed) { + matches = true; + matchDetails = "page source (substring not in allowlist)"; + } + } + } else if (indicator.code_logic.type === "substring_or_regex") { + // Try fast substring search first, fall back to regex + const substrings = indicator.code_logic.substrings || []; + const lowerSource = pageSource.toLowerCase(); + + // Fast path: check if any substring is present + for (const sub of substrings) { + if (lowerSource.includes(sub.toLowerCase())) { + matches = true; + matchDetails = "page source (substring match)"; + break; + } + } + + // Fallback: use regex if no substring matched + if (!matches && indicator.code_logic.regex) { + const pattern = new RegExp(indicator.code_logic.regex, indicator.code_logic.flags || "i"); + if (pattern.test(pageSource)) { + matches = true; + matchDetails = "page source (regex match)"; + } + } + } else if (indicator.code_logic.type === "substring_with_exclusions") { + // Check for matching patterns but exclude if exclusion phrases are present + const lowerSource = pageSource.toLowerCase(); + + // First check exclusions - if any found, skip this rule entirely + const excludeList = indicator.code_logic.exclude_if_contains || []; + const hasExclusion = excludeList.some(excl => + lowerSource.includes(excl.toLowerCase()) + ); + + if (!hasExclusion) { + // No exclusions found, now check for matches + if (indicator.code_logic.match_any) { + // Simple match - check if any phrase is present + matches = indicator.code_logic.match_any.some(phrase => + lowerSource.includes(phrase.toLowerCase()) + ); + if (matches) matchDetails = "page source (substring with exclusions)"; + } else if (indicator.code_logic.match_pattern_parts) { + // Complex match - all pattern parts must be present + const parts = indicator.code_logic.match_pattern_parts; + matches = parts.every(partGroup => + partGroup.some(part => lowerSource.includes(part.toLowerCase())) + ); + if (matches) matchDetails = "page source (pattern parts with exclusions)"; + } + } + } + } else { + // Default: regex-driven logic + const pattern = new RegExp( + indicator.pattern, + indicator.flags || "i" + ); - // Test against page source - if (pattern.test(pageSource)) { - matches = true; - matchDetails = "page source"; - } - // Test against visible text - else if (pattern.test(pageText)) { - matches = true; - matchDetails = "page text"; - } - // Test against URL - else if (pattern.test(currentUrl)) { - matches = true; - matchDetails = "URL"; - } + // Test against page source + if (pattern.test(pageSource)) { + matches = true; + matchDetails = "page source"; + } + // Test against visible text + else if (pattern.test(pageText)) { + matches = true; + matchDetails = "page text"; + } + // Test against URL + else if (pattern.test(currentUrl)) { + matches = true; + matchDetails = "URL"; + } - // Handle additional_checks - if (!matches && indicator.additional_checks) { - for (const check of indicator.additional_checks) { - if ( - pageSource.includes(check) || - pageText.includes(check) - ) { - matches = true; - matchDetails = "additional checks"; - break; + // Handle additional_checks + if (!matches && indicator.additional_checks) { + for (const check of indicator.additional_checks) { + if ( + pageSource.includes(check) || + pageText.includes(check) + ) { + matches = true; + matchDetails = "additional checks"; + break; + } } } } @@ -1963,17 +2640,176 @@ if (window.checkExtensionLoaded) { logger.warn( `🚨 PHISHING INDICATOR DETECTED: ${indicator.id} - ${indicator.description}` ); + + // PERFORMANCE: Early exit immediately when blocking threshold is reached + // Don't waste resources processing more indicators if we're already going to block + const blockThreats = threats.filter(t => t.action === 'block').length; + const criticalThreats = threats.filter(t => t.severity === 'critical').length; + const highSeverityThreats = threats.filter( + t => t.severity === 'high' || t.severity === 'critical' + ).length; + + // Exit early if: + // 1. Any blocking threat found (action='block') + // 2. Any critical severity threat found (instant block) + // 3. Multiple high/critical severity threats exceed escalation threshold + if (highSeverityThreats >= WARNING_THRESHOLD) { + const totalTime = Date.now() - startTime; + lastProcessingTime = totalTime; + + logger.log( + `⚑ EARLY EXIT: Blocking threshold reached after processing ${processedCount}/${detectionRules.phishing_indicators.length} indicators` + ); + logger.log( + ` - Block threats: ${blockThreats}` + ); + logger.log( + ` - Critical threats: ${criticalThreats}` + ); + logger.log( + ` - High+ severity threats: ${highSeverityThreats}/${WARNING_THRESHOLD}` + ); + logger.log( + `⏱️ Phishing indicators check (Main Thread - EARLY EXIT): ${threats.length} threats found, ` + + `score: ${totalScore}, time: ${totalTime}ms` + ); + resolve({ threats, score: totalScore }); + return; // Exit immediately - stop all processing + } } } catch (error) { logger.warn( `Error processing phishing indicator ${indicator.id}:`, error.message ); + } finally { + const indicatorEnd = performance.now(); + logger.log( + `⏱️ Phishing indicator [${indicator.id}] processed in ${(indicatorEnd - indicatorStart).toFixed(2)} ms` + ); } } // Continue processing if more indicators remain if (processedCount < detectionRules.phishing_indicators.length) { + // Check timeout for main thread processing + const mainThreadElapsed = Date.now() - mainThreadStartTime; + if (mainThreadElapsed > PHISHING_PROCESSING_TIMEOUT) { + const totalTime = Date.now() - startTime; + lastProcessingTime = totalTime; // CRITICAL: Track time on timeout + + logger.warn( + `⚠️ Main thread processing timeout after ${mainThreadElapsed}ms, ` + + `processed ${processedCount}/${detectionRules.phishing_indicators.length} indicators` + ); + logger.log( + `⏱️ Phishing indicators check (Main Thread - TIMEOUT): ${threats.length} threats found, ` + + `score: ${totalScore}, total time: ${totalTime}ms` + ); + + // Resolve immediately with current results for display + resolve({ threats, score: totalScore }); + + // Prevent multiple background processing cycles + if (backgroundProcessingActive) { + logger.log(`πŸ”„ Background processing already active, skipping`); + return; + } + backgroundProcessingActive = true; + + // Continue processing remaining indicators in background + const remainingIndicators = detectionRules.phishing_indicators.slice(processedCount); + logger.log(`πŸ”„ Continuing to process ${remainingIndicators.length} remaining indicators in background`); + + // Process remaining indicators asynchronously + setTimeout(async () => { + let backgroundThreatsFound = false; + + for (const indicator of remainingIndicators) { + try { + const indicatorStart = performance.now(); + let matches = false; + let matchDetails = ""; + + // Use same code-driven or regex logic + if (indicator.code_driven === true && indicator.code_logic) { + // Same code-driven logic as above + const lowerSource = pageSource.toLowerCase(); + + if (indicator.code_logic.type === "substring_or_regex") { + for (const sub of (indicator.code_logic.substrings || [])) { + if (lowerSource.includes(sub.toLowerCase())) { + matches = true; + matchDetails = "page source (substring match)"; + break; + } + } + if (!matches && indicator.code_logic.regex) { + const pattern = new RegExp(indicator.code_logic.regex, indicator.code_logic.flags || "i"); + if (pattern.test(pageSource)) { + matches = true; + matchDetails = "page source (regex match)"; + } + } + } else if (indicator.code_logic.type === "substring_with_exclusions") { + const excludeList = indicator.code_logic.exclude_if_contains || []; + const hasExclusion = excludeList.some(excl => lowerSource.includes(excl.toLowerCase())); + + if (!hasExclusion) { + if (indicator.code_logic.match_any) { + matches = indicator.code_logic.match_any.some(phrase => + lowerSource.includes(phrase.toLowerCase()) + ); + } else if (indicator.code_logic.match_pattern_parts) { + // Handle pattern parts - all groups must match + const parts = indicator.code_logic.match_pattern_parts; + matches = parts.every(partGroup => + partGroup.some(part => lowerSource.includes(part.toLowerCase())) + ); + } + } + } + } else { + const pattern = new RegExp(indicator.pattern, indicator.flags || "i"); + if (pattern.test(pageSource)) { + matches = true; + matchDetails = "page source"; + } + } + + if (matches) { + logger.log(`πŸ”„ Background processing found threat: ${indicator.id}`); + backgroundThreatsFound = true; + + // Check if we need to escalate to block mode + if (indicator.severity === 'critical' || indicator.action === 'block') { + logger.warn(`⚠️ Critical threat detected in background processing: ${indicator.id}`); + // Don't trigger re-scan immediately, just log it + // The threat will be picked up on next regular scan or page interaction + logger.warn(`πŸ’‘ Critical threat logged - will be applied on next scan`); + } + } + + const indicatorEnd = performance.now(); + logger.log(`⏱️ Background indicator [${indicator.id}] processed in ${(indicatorEnd - indicatorStart).toFixed(2)} ms`); + } catch (error) { + logger.warn(`Error in background processing of ${indicator.id}:`, error.message); + } + } + + backgroundProcessingActive = false; + logger.log(`βœ… Background processing completed. Threats found: ${backgroundThreatsFound}`); + + // If critical threats were found in background and we're not already showing a block page + // schedule a re-scan for next user interaction + if (backgroundThreatsFound && !escalatedToBlock) { + logger.log(`πŸ“‹ Critical threats found in background - will re-scan on next page change`); + } + }, 100); + + return; + } + // Use requestIdleCallback if available, otherwise setTimeout if (window.requestIdleCallback) { requestIdleCallback(processNextBatch, { timeout: 100 }); @@ -1982,6 +2818,14 @@ if (window.checkExtensionLoaded) { } } else { // Processing complete + const mainThreadTime = Date.now() - mainThreadStartTime; + const totalTime = Date.now() - startTime; + lastProcessingTime = totalTime; // CRITICAL: Track time on success + + logger.log( + `⏱️ Phishing indicators check (Main Thread): ${threats.length} threats found, ` + + `score: ${totalScore}, processing time: ${mainThreadTime}ms, total time: ${totalTime}ms` + ); resolve({ threats, score: totalScore }); } }; @@ -1993,13 +2837,14 @@ if (window.checkExtensionLoaded) { processWithIdleCallback(); }); + } catch (error) { const processingTime = Date.now() - startTime; - logger.log( - `Phishing indicators check: ${threats.length} threats found, score: ${totalScore} (${processingTime}ms)` + lastProcessingTime = processingTime; // CRITICAL: Track time on error + + logger.error( + `Error processing phishing indicators after ${processingTime}ms:`, + error.message ); - return { threats, score: totalScore }; - } catch (error) { - logger.error("Error processing phishing indicators:", error.message); return { threats: [], score: 0 }; } } @@ -2009,26 +2854,14 @@ if (window.checkExtensionLoaded) { * Now includes both detection rules exclusions AND user-configured URL allowlist */ function checkDomainExclusion(url) { - const urlObj = new URL(url); - const origin = urlObj.origin; - if (detectionRules?.exclusion_system?.domain_patterns) { - const rulesExcluded = - detectionRules.exclusion_system.domain_patterns.some((pattern) => { - try { - const regex = new RegExp(pattern, "i"); - return regex.test(origin); - } catch (error) { - logger.warn(`Invalid exclusion pattern: ${pattern}`); - return false; - } - }); - - if (rulesExcluded) { - logger.log(`βœ… URL excluded by detection rules: ${origin}`); - return true; - } + try { + const urlObj = new URL(url); + const origin = urlObj.origin; + return checkDomainExclusionByOrigin(origin); + } catch (error) { + logger.warn("Invalid URL for domain exclusion check:", url); + return false; } - return checkUserUrlAllowlist(origin); } /** @@ -2124,24 +2957,6 @@ if (window.checkExtensionLoaded) { ); } - /** - * Check for suspicious context indicators that override legitimate exclusions - */ - function checkSuspiciousContext(pageText) { - if ( - !detectionRules?.exclusion_system?.context_indicators?.suspicious_contexts - ) { - return false; - } - - const content = pageText.toLowerCase(); - return detectionRules.exclusion_system.context_indicators.suspicious_contexts.some( - (context) => { - return content.includes(context.toLowerCase()); - } - ); - } - /** * Run detection rules from rule file to calculate legitimacy score */ @@ -2415,17 +3230,45 @@ if (window.checkExtensionLoaded) { * Main protection logic following CORRECTED specification */ async function runProtection(isRerun = false) { + // Early exit if page has been escalated to block + if (escalatedToBlock) { + logger.log( + `πŸ›‘ runProtection() called but page already escalated to block - ignoring` + ); + return; + } + + // Early exit if a banner is already displayed and this is a re-run + if (isRerun && showingBanner) { + logger.log( + `πŸ›‘ runProtection() called but banner already displayed - ignoring re-scan` + ); + return; + } + try { logger.log( `πŸš€ Starting protection analysis ${ isRerun ? "(re-run)" : "(initial)" } for ${window.location.href}` ); - logger.log( - `πŸ“„ Page info: ${document.querySelectorAll("*").length} elements, ${ - document.body?.textContent?.length || 0 - } chars content` - ); + let cleanedSourceLength = null; + if (typeof arguments[1] === "object" && arguments[1]?.scanCleaned) { + // If scanCleaned is true, get cleaned page source length + const cleanedSource = getCleanPageSource(); + cleanedSourceLength = cleanedSource ? cleanedSource.length : null; + logger.log( + `πŸ“„ Page info: ${document.querySelectorAll("*").length} elements, ${ + document.body?.textContent?.length || 0 + } chars content | Cleaned page source: ${cleanedSourceLength || "N/A"} chars` + ); + } else { + logger.log( + `πŸ“„ Page info: ${document.querySelectorAll("*").length} elements, ${ + document.body?.textContent?.length || 0 + } chars content` + ); + } if (isInIframe()) { logger.log("⚠️ Page is in an iframe"); @@ -2497,15 +3340,30 @@ if (window.checkExtensionLoaded) { // Rate limiting for DOM change re-runs if (isRerun) { const now = Date.now(); - if (now - lastScanTime < SCAN_COOLDOWN || scanCount >= MAX_SCANS) { - logger.debug("Scan rate limited or max scans reached"); + const isThreatTriggeredRescan = threatTriggeredRescanCount > 0 && threatTriggeredRescanCount <= MAX_THREAT_TRIGGERED_RESCANS; + const cooldown = isThreatTriggeredRescan ? THREAT_TRIGGERED_COOLDOWN : SCAN_COOLDOWN; + + if (now - lastScanTime < cooldown || scanCount >= MAX_SCANS) { + logger.debug(`Scan rate limited (cooldown: ${cooldown}ms) or max scans reached`); return; } + + // Check if page source actually changed + if (!hasPageSourceChanged() && !isThreatTriggeredRescan) { + logger.debug("Page source unchanged, skipping re-scan"); + return; + } + lastScanTime = now; scanCount++; } else { protectionActive = true; scanCount = 1; + threatTriggeredRescanCount = 0; // Reset counter on initial run + + // Initialize page source hash + const currentSource = getPageSource(); + lastPageSourceHash = computePageSourceHash(currentSource); } logger.log( @@ -2554,19 +3412,22 @@ if (window.checkExtensionLoaded) { // Step 2: FIRST CHECK - trusted origins and Microsoft domains const currentOrigin = location.origin.toLowerCase(); + // Optimization: Single consolidated domain trust check (parses URL once) + const domainTrust = checkDomainTrust(window.location.href); + // Debug logging for domain detection logger.debug(`Checking origin: "${currentOrigin}"`); logger.debug(`Trusted login patterns:`, trustedLoginPatterns); logger.debug(`Microsoft domain patterns:`, microsoftDomainPatterns); logger.debug( - `Is trusted login domain: ${isTrustedLoginDomain(window.location.href)}` + `Is trusted login domain: ${domainTrust.isTrustedLogin}` ); logger.debug( - `Is Microsoft domain: ${isMicrosoftDomain(window.location.href)}` + `Is Microsoft domain: ${domainTrust.isMicrosoft}` ); // Check for trusted login domains (these get valid badges) - if (isTrustedLoginDomain(window.location.href)) { + if (domainTrust.isTrustedLogin) { logger.log( "βœ… TRUSTED ORIGIN - No phishing possible, exiting immediately" ); @@ -2745,7 +3606,7 @@ if (window.checkExtensionLoaded) { } // Check for general Microsoft domains (non-login pages) - if (isMicrosoftDomain(window.location.href)) { + if (domainTrust.isMicrosoft) { logger.log( "ℹ️ MICROSOFT DOMAIN (NON-LOGIN) - No phishing scan needed, no badge shown" ); @@ -2768,8 +3629,7 @@ if (window.checkExtensionLoaded) { } // Step 3: Check for domain exclusion (trusted domains) - same level as Microsoft domains - const isExcludedDomain = checkDomainExclusion(window.location.href); - if (isExcludedDomain) { + if (domainTrust.isExcluded) { logger.log( `βœ… EXCLUDED TRUSTED DOMAIN - No scanning needed, exiting immediately` ); @@ -2816,12 +3676,10 @@ if (window.checkExtensionLoaded) { ); // Step 5: Check if page is an MS logon page (using rule file requirements) - const isMSLogon = isMicrosoftLogonPage(); - if (!isMSLogon) { + const msDetection = detectMicrosoftElements(); + if (!msDetection.isLogonPage) { // Check if page has ANY Microsoft-related elements before running expensive phishing indicators - const hasMSElements = hasMicrosoftElements(); - - if (!hasMSElements) { + if (!msDetection.hasElements) { logger.log( "βœ… Page analysis result: Site appears legitimate (not Microsoft-related, no phishing indicators checked)" ); @@ -2882,7 +3740,7 @@ if (window.checkExtensionLoaded) { logger.error( "πŸ›‘οΈ PROTECTION ACTIVE: Blocking page due to critical phishing indicators" ); - showBlockingOverlay(reason, { + await showBlockingOverlay(reason, { threats: criticalThreats, score: phishingResult.score, }); @@ -2984,7 +3842,7 @@ if (window.checkExtensionLoaded) { logger.error( "πŸ›‘οΈ PROTECTION ACTIVE: Blocking page due to escalated warning threats" ); - showBlockingOverlay(reason, { + await showBlockingOverlay(reason, { threats: warningThreats, score: phishingResult.score, escalated: true, @@ -3017,6 +3875,11 @@ if (window.checkExtensionLoaded) { showWarningBanner(`SUSPICIOUS CONTENT DETECTED: ${reason}`, { threats: warningThreats, }); + + // Schedule threat-triggered re-scan to catch additional late-loading threats + if (!isRerun && warningThreats.length > 0) { + scheduleThreatTriggeredRescan(warningThreats.length); + } } const redirectHostname = extractRedirectHostname(location.href); @@ -3300,7 +4163,7 @@ if (window.checkExtensionLoaded) { logger.warn("Failed to send page_blocked webhook:", err.message); }); - showBlockingOverlay(blockingResult.reason, blockingResult); + await showBlockingOverlay(blockingResult.reason, blockingResult); disableFormSubmissions(); disableCredentialInputs(); stopDOMMonitoring(); @@ -3416,7 +4279,7 @@ if (window.checkExtensionLoaded) { logger.warn("Failed to send page_blocked webhook:", err.message); }); - showBlockingOverlay(reason, { + await showBlockingOverlay(reason, { threats: criticalBlockingRules.map((rule) => ({ description: rule.description, severity: "critical", @@ -3531,6 +4394,11 @@ if (window.checkExtensionLoaded) { phishingIndicators: criticalThreats.map((t) => t.id), }; + // Schedule threat-triggered re-scan to catch additional late-loading threats + if (!isRerun && criticalThreats.length > 0) { + scheduleThreatTriggeredRescan(criticalThreats.length); + } + if (protectionEnabled) { logger.error( "πŸ›‘οΈ PROTECTION ACTIVE: Blocking page due to critical phishing indicators" @@ -3554,7 +4422,7 @@ if (window.checkExtensionLoaded) { logger.warn("Failed to send page_blocked webhook:", err.message); }); - showBlockingOverlay(reason, { + await showBlockingOverlay(reason, { threats: criticalThreats, score: phishingResult.score, }); @@ -3634,7 +4502,7 @@ if (window.checkExtensionLoaded) { logger.error( "πŸ›‘οΈ PROTECTION ACTIVE: Blocking due to very low detection score" ); - showBlockingOverlay(reason, { + await showBlockingOverlay(reason, { threats: [{ description: reason, severity: "high" }], score: detectionResult.score, }); @@ -3744,7 +4612,7 @@ if (window.checkExtensionLoaded) { logger.warn("Failed to send page_blocked webhook:", err.message); }); - showBlockingOverlay(reason, lastDetectionResult); + await showBlockingOverlay(reason, lastDetectionResult); disableFormSubmissions(); disableCredentialInputs(); stopDOMMonitoring(); // Stop monitoring once blocked @@ -3761,6 +4629,11 @@ if (window.checkExtensionLoaded) { setupDynamicScriptMonitoring(); } } + + // Schedule threat-triggered re-scan for high/medium threats + if (!isRerun && allThreats.length > 0) { + scheduleThreatTriggeredRescan(allThreats.length); + } } else { logger.warn(`⚠️ ANALYSIS: MEDIUM THREAT detected - ${reason}`); if (protectionEnabled) { @@ -3780,6 +4653,11 @@ if (window.checkExtensionLoaded) { setupDOMMonitoring(); setupDynamicScriptMonitoring(); } + + // Schedule threat-triggered re-scan for medium threats + if (!isRerun && allThreats.length > 0) { + scheduleThreatTriggeredRescan(allThreats.length); + } } const redirectHostname = extractRedirectHostname(location.href); @@ -3915,6 +4793,7 @@ if (window.checkExtensionLoaded) { /** * Set up DOM monitoring to catch delayed phishing content */ + let domScanTimeout = null; // Debounce timer for DOM-triggered scans function setupDOMMonitoring() { try { // Don't set up multiple observers @@ -3931,8 +4810,14 @@ if (window.checkExtensionLoaded) { `Body content length: ${document.body?.textContent?.length || 0} chars` ); - domObserver = new MutationObserver(async (mutations) => { + domObserver = new MutationObserver(async (mutations) => { try { + // Immediately exit if page has been escalated to block + if (escalatedToBlock) { + logger.debug("πŸ›‘ Page escalated to block - ignoring DOM mutations"); + return; + } + let shouldRerun = false; let newElementsAdded = false; @@ -3942,6 +4827,12 @@ if (window.checkExtensionLoaded) { // Check for added forms, inputs, or scripts for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { + // Skip extension-injected elements (banner, badges, overlays, etc.) + if (injectedElements.has(node)) { + logger.debug(`Skipping extension-injected element: ${node.tagName?.toLowerCase()} (ID: ${node.id})`); + continue; + } + newElementsAdded = true; const tagName = node.tagName?.toLowerCase(); @@ -4043,7 +4934,7 @@ if (window.checkExtensionLoaded) { if (shouldRerun) break; } - if (shouldRerun && !showingBanner) { + if (shouldRerun && !showingBanner && !escalatedToBlock) { // Check scan rate limiting if (scanCount >= MAX_SCANS) { logger.log( @@ -4053,19 +4944,33 @@ if (window.checkExtensionLoaded) { } logger.log( - "πŸ”„ Significant DOM changes detected - re-running protection analysis" + "πŸ”„ Significant DOM changes detected - scheduling protection analysis (debounced)" ); logger.log( `Page now has ${document.querySelectorAll("*").length} elements` ); - // Enhanced debounce delay from 500ms to 1000ms for performance - setTimeout(() => { + // Debounce: clear any pending scan and schedule a new one + if (domScanTimeout) { + clearTimeout(domScanTimeout); + } + domScanTimeout = setTimeout(() => { runProtection(true); + domScanTimeout = null; }, 1000); + } else if (escalatedToBlock) { + logger.debug("πŸ›‘ Page escalated to block - ignoring DOM changes during debounce check"); } else if (showingBanner) { logger.debug( - "🚫 Ignoring DOM changes while banner is being displayed" + "πŸ” DOM changes detected while banner is displayed - scanning cleaned page source (debounced)" ); + // Debounce: clear any pending scan and schedule a new one + if (domScanTimeout) { + clearTimeout(domScanTimeout); + } + domScanTimeout = setTimeout(() => { + runProtection(true, { scanCleaned: true }); + domScanTimeout = null; + }, 1000); } else if (newElementsAdded) { logger.debug( "πŸ” DOM changes detected but not significant enough to re-run analysis" @@ -4085,10 +4990,20 @@ if (window.checkExtensionLoaded) { // Fallback: Check periodically for content that might have loaded without triggering observer const checkInterval = setInterval(() => { + // Stop if page has been escalated to block + if (escalatedToBlock) { + logger.debug("πŸ›‘ Page escalated to block - stopping fallback timer"); + clearInterval(checkInterval); + return; + } + if (showingBanner) { logger.debug( - "🚫 Fallback timer skipping check while banner is displayed" + "πŸ” Fallback timer scanning cleaned page source while banner is displayed" ); + // Scan cleaned page source (banner and injected elements removed) + runProtection(true, { scanCleaned: true }); + clearInterval(checkInterval); return; } @@ -4108,6 +5023,11 @@ if (window.checkExtensionLoaded) { setTimeout(() => { clearInterval(checkInterval); stopDOMMonitoring(); + // Also clear any pending DOM scan debounce + if (domScanTimeout) { + clearTimeout(domScanTimeout); + domScanTimeout = null; + } logger.log("πŸ›‘ DOM monitoring timeout reached - stopping"); }, 30000); } catch (error) { @@ -4125,6 +5045,13 @@ if (window.checkExtensionLoaded) { domObserver = null; logger.log("DOM monitoring stopped"); } + + // Also clear any scheduled threat-triggered re-scans + if (scheduledRescanTimeout) { + clearTimeout(scheduledRescanTimeout); + scheduledRescanTimeout = null; + logger.log("Cleared scheduled threat-triggered re-scan"); + } } catch (error) { logger.error("Failed to stop DOM monitoring:", error.message); } @@ -4133,8 +5060,15 @@ if (window.checkExtensionLoaded) { /** * Block page by redirecting to Chrome blocking page - NO USER OVERRIDE */ - function showBlockingOverlay(reason, analysisData) { + async function showBlockingOverlay(reason, analysisData) { try { + // CRITICAL: Set escalated to block flag FIRST to prevent any further scans + escalatedToBlock = true; + + // CRITICAL: Immediately stop all monitoring and processing to save resources + // The page is being blocked, so no further analysis is needed + stopDOMMonitoring(); + logger.log( "Redirecting to Chrome blocking page for security - no user override allowed" ); @@ -4183,7 +5117,8 @@ if (window.checkExtensionLoaded) { logger.log("Enriched blocking details:", blockingDetails); // Store debug data before redirect so it can be retrieved on blocked page - storeDebugDataBeforeRedirect(location.href, analysisData); + // IMPORTANT: Wait for storage to complete before redirecting to avoid race condition + await storeDebugDataBeforeRedirect(location.href, analysisData); // Encode the details for the blocking page const encodedDetails = encodeURIComponent( @@ -4200,50 +5135,46 @@ if (window.checkExtensionLoaded) { } catch (error) { logger.error("Failed to redirect to blocking page:", error.message); - // Fallback: Replace page content entirely if redirect fails + // Fallback: Replace page content try { - document.documentElement.innerHTML = ` - - - - Site Blocked - Microsoft 365 Protection - - - -
-
πŸ›‘οΈ
-

Phishing Site Blocked

+ // Create fallback overlay + const overlay = document.createElement("div"); + overlay.id = "ms365-blocking-overlay"; + overlay.style.cssText = ` + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + background: white !important; + z-index: 2147483647 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + `; + + // CRITICAL: Register overlay before adding to DOM + registerInjectedElement(overlay); + + overlay.innerHTML = ` +
+
πŸ›‘οΈ
+

Phishing Site Blocked

Microsoft 365 login page detected on suspicious domain.

This site may be attempting to steal your credentials and has been blocked for your protection.

-
Reason: ${reason}
-
Blocked by: Check
-
No override available - contact your administrator if this is incorrect
+
Reason: ${reason}
+
Blocked by: Check
+
No override available - contact your administrator if this is incorrect
- - - `; - - logger.log("Fallback page content replacement completed"); + `; + + document.body.appendChild(overlay); + + // Register all child elements + const allChildren = overlay.querySelectorAll("*"); + allChildren.forEach(child => registerInjectedElement(child)); + + logger.log("Fallback page content replacement completed with element tracking"); } catch (fallbackError) { logger.error( "Fallback page replacement failed:", @@ -4367,49 +5298,9 @@ if (window.checkExtensionLoaded) { }: ${threat.description || threat.reason || "Threat detected"}` ) .join("\n"); - } else if ( - details.foundThreats && - Array.isArray(details.foundThreats) - ) { - return details.foundThreats - .map( - (threat) => - `- ${threat.id || threat}: ${threat.description || "Detected"}` - ) - .join("\n"); - } else if (details.indicators && Array.isArray(details.indicators)) { - return details.indicators - .map( - (indicator) => - `- ${indicator.id}: ${indicator.description || indicator.id} (${ - indicator.severity || "unknown" - })` - ) - .join("\n"); - } else if ( - details.foundIndicators && - Array.isArray(details.foundIndicators) - ) { - return details.foundIndicators - .map( - (indicator) => - `- ${indicator.id || indicator}: ${indicator.description || ""}` - ) - .join("\n"); - } else { - // Fallback: Look for any array properties that might contain indicators - const arrayProps = Object.keys(details).filter( - (key) => Array.isArray(details[key]) && details[key].length > 0 - ); - - if (arrayProps.length > 0) { - return `Multiple indicators detected (${ - details.reason || "see browser console for details" - })`; - } else { - return `${details.reason || "Unknown detection criteria"}`; - } } + + return `${details.reason || "Unknown detection criteria"}`; }; const applyBranding = (bannerEl, branding) => { @@ -4424,17 +5315,23 @@ if (window.checkExtensionLoaded) { if (!logoUrl) { logoUrl = packagedFallback; } + let brandingSlot = bannerEl.querySelector("#check-banner-branding"); if (!brandingSlot) { const container = document.createElement("div"); container.id = "check-banner-branding"; container.style.cssText = "display:flex;align-items:center;gap:8px;"; + + // CRITICAL: Register the branding container + registerInjectedElement(container); + const innerWrapper = bannerEl.firstElementChild; if (innerWrapper) innerWrapper.insertBefore(container, innerWrapper.firstChild); brandingSlot = container; } + if (brandingSlot) { brandingSlot.innerHTML = ""; if (logoUrl) { @@ -4443,15 +5340,27 @@ if (window.checkExtensionLoaded) { img.alt = companyName + " logo"; img.style.cssText = "width:28px;height:28px;object-fit:contain;border-radius:4px;background:rgba(255,255,255,0.25);padding:2px;"; + + // CRITICAL: Register the logo image + registerInjectedElement(img); brandingSlot.appendChild(img); } + const textWrap = document.createElement("div"); textWrap.style.cssText = "display:flex;flex-direction:column;align-items:flex-start;line-height:1.2;"; + + // CRITICAL: Register the text wrapper + registerInjectedElement(textWrap); + const titleSpan = document.createElement("span"); titleSpan.style.cssText = "font-size:12px;font-weight:600;"; titleSpan.textContent = "Protected by " + companyName; + + // CRITICAL: Register the title span + registerInjectedElement(titleSpan); textWrap.appendChild(titleSpan); + if (supportEmail) { const contactDiv = document.createElement("div"); const contactLink = document.createElement("a"); @@ -4471,12 +5380,14 @@ if (window.checkExtensionLoaded) { reason, }); } catch (_) {} + let indicatorsText; try { indicatorsText = extractPhishingIndicators(analysisData); } catch (err) { indicatorsText = "Parse error - see console"; } + const detectionScoreLine = analysisData?.score !== undefined ? `Detection Score: ${analysisData.score}/${analysisData.threshold}` @@ -4493,6 +5404,11 @@ if (window.checkExtensionLoaded) { subject )}&body=${body}`; }); + + // CRITICAL: Register contact elements + registerInjectedElement(contactDiv); + registerInjectedElement(contactLink); + contactDiv.appendChild(contactLink); textWrap.appendChild(contactDiv); } @@ -4539,19 +5455,19 @@ if (window.checkExtensionLoaded) { // Layout: left branding slot, absolutely centered message block, dismiss button on right. const bannerContent = ` -
-
-
- ${bannerIcon} - ${bannerTitle} - ${reason}${detailsText} -
- -
`; +
+
+
+ ${bannerIcon} + ${bannerTitle} + ${reason}${detailsText} +
+ +
`; // Check if banner already exists let banner = document.getElementById("ms365-warning-banner"); @@ -4574,29 +5490,35 @@ if (window.checkExtensionLoaded) { banner = document.createElement("div"); banner.id = "ms365-warning-banner"; banner.style.cssText = ` - position: fixed !important; - top: 0 !important; - left: 0 !important; - width: 100% !important; - background: ${bannerColor} !important; - color: white !important; - padding: 16px !important; - z-index: 2147483646 !important; - font-family: system-ui, -apple-system, sans-serif !important; - box-shadow: 0 2px 8px rgba(0,0,0,0.3) !important; - text-align: center !important; - `; + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + background: ${bannerColor} !important; + color: white !important; + padding: 16px !important; + z-index: 2147483646 !important; + font-family: system-ui, -apple-system, sans-serif !important; + box-shadow: 0 2px 8px rgba(0,0,0,0.3) !important; + text-align: center !important; + `; + + // CRITICAL: Register the banner BEFORE adding to DOM + registerInjectedElement(banner); banner.innerHTML = bannerContent; - document.body.appendChild(banner); + document.body.insertBefore(banner, document.body.firstChild); + + // Register all child elements created via innerHTML + const allChildren = banner.querySelectorAll("*"); + allChildren.forEach(child => registerInjectedElement(child)); fetchBranding().then((branding) => applyBranding(banner, branding)); - // Push page content down to avoid covering login header - const bannerHeight = banner.offsetHeight || 64; // fallback height + const bannerHeight = banner.offsetHeight || 64; document.body.style.marginTop = `${bannerHeight}px`; - logger.log("Warning banner displayed"); + logger.log("Warning banner displayed and all elements registered for exclusion"); } catch (error) { logger.error("Failed to show warning banner:", error.message); showingBanner = false; @@ -4606,7 +5528,9 @@ if (window.checkExtensionLoaded) { /** * Show valid badge for trusted domains */ - function showValidBadge() { + let validBadgeTimeoutId = null; // Store timeout ID for cleanup + + async function showValidBadge() { try { // Check if badge already exists - for valid badge, we don't need to update content // since it's always the same, but we ensure it's still visible @@ -4615,6 +5539,27 @@ if (window.checkExtensionLoaded) { return; } + // Clear any existing timeout from previous badge + if (validBadgeTimeoutId) { + clearTimeout(validBadgeTimeoutId); + validBadgeTimeoutId = null; + } + + // Load timeout configuration + const config = await new Promise((resolve) => { + chrome.storage.local.get(["config"], (result) => { + resolve(result.config || {}); + }); + }); + + // Get timeout value (default to 5 seconds if not configured) + // A value of 0 means no timeout (badge stays until manually dismissed) + const timeoutSeconds = config.validPageBadgeTimeout !== undefined + ? config.validPageBadgeTimeout + : 5; + + logger.debug(`Valid badge timeout configured: ${timeoutSeconds} seconds (0 = no timeout)`); + // Check if mobile using media query (more conservative breakpoint) const isMobile = window.matchMedia("(max-width: 480px)").matches; @@ -4651,7 +5596,7 @@ if (window.checkExtensionLoaded) { Verified Microsoft Domain
This is an authentic Microsoft login page
-