Conversation
…port Add domain squatting detection with typosquatting, homoglyphs, combosquatting, Levenshtein distance, configurable page blocking, CIPP reporting, webhook integration, and unified URL allowlist configuration
There was a problem hiding this comment.
Pull request overview
Adds first-class domain-squatting (typosquatting/homoglyph/combosquatting) protection to the extension, wiring it into the scan pipeline, blocked-page UX, options UI, rules configuration, and documentation. It also tweaks scan cadence/timing to better handle dynamically loaded pages.
Changes:
- Introduce a
DomainSquattingDetectormodule and acheck_domain_squattingbackground message handler, backed by newdomain_squattingrules configuration. - Run a domain-squatting pre-check in the content script (before phishing detection) and emit logs/CIPP/webhooks; update blocked-page rendering for domain-squatting events.
- Improve options “Configuration Overview” UX with collapsible sections and add docs for the new feature.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/modules/domain-squatting-detector.js | New detector implementing multiple squatting techniques + allowlist-domain extraction. |
| scripts/content.js | Adds domain-squatting pre-check; adjusts scan cadence and adds additional rescan triggers/timing logic. |
| scripts/background.js | Instantiates/initializes detector and exposes check_domain_squatting message handling. |
| scripts/blocked.js | Adds domain-squatting specific labeling + technical-details rendering. |
| rules/detection-rules.json | Adds domain_squatting configuration block and default protected domains. |
| options/options.js | Collapsible config UI + expand/collapse-all; adds webhook event type; enterprise-mode UX tweaks. |
| options/options.html | Adds webhook checkbox + expand/collapse-all button; updates allowlist description. |
| options/options.css | Styles for collapsible config sections/lists. |
| docs/settings/detection-rules.md | Documents allowlist “dual protection” behavior. |
| docs/features/domain-squatting-detection.md | New feature documentation page. |
| docs/SUMMARY.md | Adds the new feature doc to the sidebar. |
| config/managed_schema.json | Adds managed policy schema entries for domain squatting configuration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "Action": { | ||
| "title": "Action", | ||
| "description": "Action to take when domain squatting is detected", | ||
| "type": "string", | ||
| "enum": ["block", "warn", "log"], | ||
| "default": "block" | ||
| }, |
There was a problem hiding this comment.
In the managed policy schema, the property name is "Action" (capital A) while other schema keys are lowerCamelCase. This is likely a typo and will make policy payloads inconsistent/confusing (admins will naturally try action). Rename to action (and keep enum/default) to match the rest of the schema style.
| **Adjust Sensitivity:** | ||
| "enabled": true | ||
| } | ||
| } | ||
| ``` | ||
|
|
There was a problem hiding this comment.
The “Adjust Sensitivity” section has a broken Markdown code block: there’s a closing ``` without a matching opening fence, and stray JSON fragment lines render incorrectly. Clean up this section so the JSON examples are inside proper fenced blocks (or remove the stray fragment) to avoid confusing readers.
| **Adjust Sensitivity:** | |
| "enabled": true | |
| } | |
| } | |
| ``` |
| const domainSquattingResult = await chrome.runtime.sendMessage({ | ||
| type: "check_domain_squatting", | ||
| domain: window.location.hostname | ||
| }); |
There was a problem hiding this comment.
Domain squatting is checked via chrome.runtime.sendMessage on every runProtection scan, but the hostname doesn’t change during a page session. This can cause repeated background messaging/logging and repeated warnings across rescans. Cache the result per window.location.hostname (or add a one-time domainSquattingChecked flag) so the expensive check runs at most once per page load unless the hostname actually changes.
| await showBlockingOverlay( | ||
| `Domain Squatting: This site closely resembles "${squattingData.protectedDomain}" but is not the legitimate site`, | ||
| { | ||
| type: "domain_squatting", | ||
| severity: squattingData.severity, | ||
| testDomain: squattingData.testDomain, | ||
| protectedDomain: squattingData.protectedDomain, | ||
| techniques: squattingData.techniques, | ||
| confidence: squattingData.confidence, | ||
| reason: `Domain squatting detected: ${techniquesDesc}`, | ||
| detectionMethod: "domain-squatting", | ||
| detectionTime: Date.now() | ||
| } | ||
| ); |
There was a problem hiding this comment.
The domain-squatting block path passes type/severity/testDomain/protectedDomain/techniques/confidence to showBlockingOverlay(), but showBlockingOverlay() currently serializes only phishing-centric fields (score, threshold, threats, etc.) into the details query param. As a result, blocked.js won’t receive details.type === "domain_squatting", and the new domain-squatting UI/technical details won’t render. Extend showBlockingOverlay() to include/passthrough these domain-squatting fields in blockingDetails when present.
| // Intercept fetch | ||
| const originalFetch = window.fetch; | ||
| window.fetch = function(...args) { | ||
| pendingRequests++; | ||
| lastNetworkActivity = Date.now(); | ||
| logger.debug(`🌐 Fetch request started (pending: ${pendingRequests})`); | ||
|
|
||
| return originalFetch.apply(this, arguments).finally(() => { | ||
| pendingRequests--; | ||
| lastNetworkActivity = Date.now(); | ||
| logger.debug(`🌐 Fetch request completed (pending: ${pendingRequests})`); | ||
| checkNetworkIdle(); | ||
| }); | ||
| }; | ||
|
|
||
| // Intercept XMLHttpRequest | ||
| const originalOpen = XMLHttpRequest.prototype.open; | ||
| const originalSend = XMLHttpRequest.prototype.send; | ||
|
|
||
| XMLHttpRequest.prototype.open = function(...args) { | ||
| this._check_tracked = true; | ||
| return originalOpen.apply(this, args); | ||
| }; | ||
|
|
||
| XMLHttpRequest.prototype.send = function(...args) { | ||
| if (this._check_tracked) { | ||
| pendingRequests++; | ||
| lastNetworkActivity = Date.now(); | ||
| logger.debug(`🌐 XHR request started (pending: ${pendingRequests})`); | ||
|
|
||
| this.addEventListener('loadend', () => { | ||
| pendingRequests--; | ||
| lastNetworkActivity = Date.now(); | ||
| logger.debug(`🌐 XHR request completed (pending: ${pendingRequests})`); | ||
| checkNetworkIdle(); | ||
| }); | ||
| } | ||
| return originalSend.apply(this, args); | ||
| }; |
There was a problem hiding this comment.
setupNetworkMonitoring() overrides window.fetch and XMLHttpRequest to infer “network idle”, but scripts/content.js is a content script (runs in an isolated world). Overriding these globals won’t observe the page’s own network requests, so “network idle” will not reflect actual page loading behavior. If the goal is to track page network activity, use chrome.scripting.executeScript({ world: 'MAIN' ... }) to inject monitoring into the main world, or track requests from the background via webRequest/tabs APIs.
| // Intercept fetch | |
| const originalFetch = window.fetch; | |
| window.fetch = function(...args) { | |
| pendingRequests++; | |
| lastNetworkActivity = Date.now(); | |
| logger.debug(`🌐 Fetch request started (pending: ${pendingRequests})`); | |
| return originalFetch.apply(this, arguments).finally(() => { | |
| pendingRequests--; | |
| lastNetworkActivity = Date.now(); | |
| logger.debug(`🌐 Fetch request completed (pending: ${pendingRequests})`); | |
| checkNetworkIdle(); | |
| }); | |
| }; | |
| // Intercept XMLHttpRequest | |
| const originalOpen = XMLHttpRequest.prototype.open; | |
| const originalSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function(...args) { | |
| this._check_tracked = true; | |
| return originalOpen.apply(this, args); | |
| }; | |
| XMLHttpRequest.prototype.send = function(...args) { | |
| if (this._check_tracked) { | |
| pendingRequests++; | |
| lastNetworkActivity = Date.now(); | |
| logger.debug(`🌐 XHR request started (pending: ${pendingRequests})`); | |
| this.addEventListener('loadend', () => { | |
| pendingRequests--; | |
| lastNetworkActivity = Date.now(); | |
| logger.debug(`🌐 XHR request completed (pending: ${pendingRequests})`); | |
| checkNetworkIdle(); | |
| }); | |
| } | |
| return originalSend.apply(this, args); | |
| }; | |
| // Listen for network activity messages from the page's main world | |
| window.addEventListener( | |
| "message", | |
| function(event) { | |
| // Ensure messages originate from this page and have our marker | |
| if (!event || event.source !== window || !event.data) { | |
| return; | |
| } | |
| const data = event.data; | |
| if (!data || !data.__m365NetworkActivity) { | |
| return; | |
| } | |
| if (typeof data.delta === "number") { | |
| pendingRequests += data.delta; | |
| // Guard against negative counts in case of unexpected sequences | |
| if (pendingRequests < 0) { | |
| pendingRequests = 0; | |
| } | |
| lastNetworkActivity = Date.now(); | |
| if (data.kind === "fetchStart" || data.kind === "xhrStart") { | |
| logger.debug( | |
| `🌐 ${data.kind} request started (pending: ${pendingRequests})` | |
| ); | |
| } else if (data.kind === "fetchEnd" || data.kind === "xhrEnd") { | |
| logger.debug( | |
| `🌐 ${data.kind} request completed (pending: ${pendingRequests})` | |
| ); | |
| } | |
| checkNetworkIdle(); | |
| } | |
| }, | |
| false | |
| ); | |
| // Inject a script into the page's main world to monitor network activity | |
| const script = document.createElement("script"); | |
| script.textContent = | |
| "(" + | |
| function() { | |
| try { | |
| var originalFetch = window.fetch; | |
| if (typeof originalFetch === "function") { | |
| window.fetch = function() { | |
| window.postMessage( | |
| { | |
| __m365NetworkActivity: true, | |
| delta: +1, | |
| kind: "fetchStart", | |
| }, | |
| "*" | |
| ); | |
| return originalFetch | |
| .apply(this, arguments) | |
| .finally(function() { | |
| window.postMessage( | |
| { | |
| __m365NetworkActivity: true, | |
| delta: -1, | |
| kind: "fetchEnd", | |
| }, | |
| "*" | |
| ); | |
| }); | |
| }; | |
| } | |
| var originalOpen = XMLHttpRequest.prototype.open; | |
| var originalSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function() { | |
| this.__m365Tracked = true; | |
| return originalOpen.apply(this, arguments); | |
| }; | |
| XMLHttpRequest.prototype.send = function() { | |
| if (this.__m365Tracked) { | |
| window.postMessage( | |
| { | |
| __m365NetworkActivity: true, | |
| delta: +1, | |
| kind: "xhrStart", | |
| }, | |
| "*" | |
| ); | |
| this.addEventListener("loadend", function() { | |
| window.postMessage( | |
| { | |
| __m365NetworkActivity: true, | |
| delta: -1, | |
| kind: "xhrEnd", | |
| }, | |
| "*" | |
| ); | |
| }); | |
| } | |
| return originalSend.apply(this, arguments); | |
| }; | |
| } catch (e) { | |
| // Fail-safe: do not break the page if monitoring cannot be injected | |
| } | |
| } + | |
| ")();"; | |
| (document.head || document.documentElement).appendChild(script); | |
| if (script.parentNode) { | |
| script.parentNode.removeChild(script); | |
| } |
| // Patch into config loading logic | ||
| (function() { | ||
| const origShowConfig = window.showConfigDisplay; | ||
| window.showConfigDisplay = function(config) { | ||
| if (typeof origShowConfig === 'function') origShowConfig(config); | ||
| renderConfigSummary(config); | ||
| document.getElementById('configSummary').style.display = ''; |
There was a problem hiding this comment.
This patch hooks config summary rendering by monkey-patching window.showConfigDisplay, but the options page uses CheckOptions.updateConfigDisplay() / displayConfigInCard() (there is no showConfigDisplay elsewhere). As written, renderConfigSummary() may never run. Call renderConfigSummary(this.currentConfigData) inside updateConfigDisplay() (or after displayConfigInCard()), or expose a real hook instead of patching a non-existent global.
| // Patch into config loading logic | |
| (function() { | |
| const origShowConfig = window.showConfigDisplay; | |
| window.showConfigDisplay = function(config) { | |
| if (typeof origShowConfig === 'function') origShowConfig(config); | |
| renderConfigSummary(config); | |
| document.getElementById('configSummary').style.display = ''; | |
| // Patch into config loading logic by wrapping CheckOptions.updateConfigDisplay | |
| (function() { | |
| const proto = CheckOptions && CheckOptions.prototype; | |
| if (!proto || typeof proto.updateConfigDisplay !== 'function') { | |
| return; | |
| } | |
| const origUpdateConfigDisplay = proto.updateConfigDisplay; | |
| proto.updateConfigDisplay = function(...args) { | |
| const result = origUpdateConfigDisplay.apply(this, args); | |
| // Render minimal config summary based on the current config data | |
| try { | |
| if (this && this.currentConfigData) { | |
| renderConfigSummary(this.currentConfigData); | |
| const summaryEl = document.getElementById('configSummary'); | |
| if (summaryEl) { | |
| summaryEl.style.display = ''; | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Failed to render config summary:', e); | |
| } | |
| return result; |
| async initialize(config, urlAllowlist = []) { | ||
| try { | ||
| if (config.domain_squatting) { | ||
| this.enabled = config.domain_squatting.enabled !== false; | ||
| this.protectedDomains = config.domain_squatting.protected_domains || []; | ||
| this.deviationThreshold = config.domain_squatting.deviation_threshold || 2; | ||
|
|
||
| if (config.domain_squatting.algorithms) { | ||
| this.algorithms = { ...this.algorithms, ...config.domain_squatting.algorithms }; | ||
| } | ||
| } | ||
|
|
||
| // Extract domains from URL allowlist patterns | ||
| const allowlistDomains = this.extractDomainsFromAllowlist(urlAllowlist); | ||
| if (allowlistDomains.length > 0) { | ||
| // Merge with protected domains from rules (avoid duplicates) | ||
| const allDomains = [...new Set([...this.protectedDomains, ...allowlistDomains])]; | ||
| this.protectedDomains = allDomains; | ||
| logger.log(`Added ${allowlistDomains.length} domains from URL allowlist`); | ||
| } | ||
|
|
||
| logger.log('DomainSquattingDetector initialized:', { | ||
| enabled: this.enabled, | ||
| protectedDomains: this.protectedDomains.length, | ||
| fromRules: config.domain_squatting?.protected_domains?.length || 0, | ||
| fromAllowlist: allowlistDomains.length, | ||
| deviationThreshold: this.deviationThreshold | ||
| }); |
There was a problem hiding this comment.
initialize(config, urlAllowlist) assumes config is a non-null object. If config is undefined/null (e.g., when background.js calls initialize with cachedRules not yet loaded), if (config.domain_squatting) and the later log line fromRules: config.domain_squatting?... will throw and the detector won’t initialize. Guard with config?.domain_squatting and default to {} (or early-return) so reinitialization can safely run even when rules aren’t available yet.
| // Extract domain without subdomain and TLD for comparison | ||
| const testBase = this.extractBaseDomain(testDomain); | ||
|
|
||
| for (const protectedDomain of this.protectedDomains) { | ||
| const protectedBase = this.extractBaseDomain(protectedDomain); | ||
|
|
||
| // Skip if domains are identical | ||
| if (testBase === protectedBase) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
checkDomain() compares only the “base” label returned by extractBaseDomain() and skips when testBase === protectedBase. This makes the detector miss common squatting variants that only change the TLD (e.g., microsoft.net vs microsoft.com) or public-suffix differences, because both bases become microsoft and the check is skipped. Consider comparing the registrable domain (eTLD+1) instead of a single label, or at least include the TLD in comparisons and only skip when the full registrable domains match.
Co-authored-by: Copilot <[email protected]> Signed-off-by: Zacgoose <[email protected]>
Co-authored-by: Copilot <[email protected]> Signed-off-by: Zacgoose <[email protected]>
No description provided.