diff --git a/.gitignore b/.gitignore index 04bea7fa..dfeb638c 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,7 @@ config/local-config.json config/development.json config/production.json config/test.json +manifest.chrome.json # Sensitive configuration files config/secrets.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a632d09..3774c483 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,32 @@ # Contributing to Check -Thanks for taking the time to contribute! These guidelines help keep contributions consistent and reliable for this Chrome extension. +Thanks for taking the time to contribute! These guidelines help keep contributions consistent and reliable for this cross-browser extension. ## Development Setup + +### Chrome/Edge - Fork the repository and clone your fork. -- In Chrome or a Chromium-based browser, open `chrome://extensions`. +- Run `npm run build:chrome` to configure for Chrome/Edge. +- In Chrome or Edge, open `chrome://extensions` or `edge://extensions`. - Enable **Developer mode** and choose **Load unpacked**. - Select the repository root to load the extension. Reload the extension after making changes. +### Firefox +- Fork the repository and clone your fork. +- Run `npm run build:firefox` to configure for Firefox. +- In Firefox, open `about:debugging#/runtime/this-firefox`. +- Click **Load Temporary Add-on** and select `manifest.json`. +- Reload the extension after making changes. +- See [Firefox Support Guide](docs/firefox-support.md) for more details. + +## Cross-Browser Compatibility +- The extension supports Chrome, Edge, and Firefox through browser polyfills. +- Always test changes in both Chrome and Firefox before submitting. +- Use the browser polyfill APIs in your code: + - In ES modules: `import { chrome, storage } from "./browser-polyfill.js"` + - In traditional scripts: The polyfill is auto-loaded, just use `chrome.*` as normal +- Avoid browser-specific features unless absolutely necessary. + ## Coding Standards (ESLint) - No ESLint configuration is committed to the repository. Maintain the existing code style (2 spaces, semicolons, ES modules). - If you have ESLint installed locally, run `npx eslint scripts options popup` with the default recommended rules and resolve any issues before committing. @@ -18,10 +37,12 @@ Thanks for taking the time to contribute! These guidelines help keep contributio ## Testing Expectations - Automated tests are not currently available. Manually test changes by loading the extension and verifying: - - The background service worker initializes without errors. + - The background service worker/script initializes without errors. - Content scripts inject and execute as expected. - Options and popup pages function correctly. -- Include a brief summary of manual testing in your pull request. +- **Test in both Chrome and Firefox** to ensure cross-browser compatibility. +- See [Firefox Support Guide](docs/firefox-support.md) for Firefox testing instructions. +- Include a brief summary of manual testing in your pull request, noting which browsers were tested. ## Scripted Deployment Updates - Any new configuration settings result in a need to be managed by scripted deployment. As such, the following files need to be reviewed and have the settings added: diff --git a/README.md b/README.md index 0a3ea05e..4ff9b97d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ impersonate Microsoft 365 sign-in pages. Install it from the [Chrome](https://chromewebstore.google.com/detail/benimdeioplgkhanklclahllklceahbe) Store here or the [Edge](https://microsoftedge.microsoft.com/addons/detail/check-by-cyberdrain/knepjpocdagponkonnbggpcnhnaikajg) store here. +**Firefox Support**: The extension also works on Firefox 109+. See [Firefox Support](docs/firefox-support.md) for installation instructions. + ## Features - **Detection engine** – loads rules from `rules/detection-rules.json` and @@ -21,7 +23,7 @@ Install it from the [Chrome](https://chromewebstore.google.com/detail/benimdeiop ## Requirements -- Chrome 88+ or other Chromium-based browsers supporting Manifest V3 +- Chrome 88+, Edge 88+, or Firefox 109+ (browsers supporting Manifest V3) - Optional enterprise management via Group Policy or Microsoft Intune for policy enforcement @@ -29,11 +31,19 @@ Install it from the [Chrome](https://chromewebstore.google.com/detail/benimdeiop ### Manual +#### Chrome/Edge 1. Clone this repository. -2. In Chrome/Edge open `chrome://extensions/` and enable **Developer mode**. +2. In Chrome/Edge open `chrome://extensions/` or `edge://extensions` and enable **Developer mode**. 3. Click **Load unpacked** and select the project directory. 4. Verify the extension using `test-extension-loading.html`. +#### Firefox +1. Clone this repository. +2. Run `npm run build:firefox` to configure for Firefox. +3. Open `about:debugging#/runtime/this-firefox` in Firefox. +4. Click **Load Temporary Add-on** and select `manifest.json`. +5. For more details, see [Firefox Support](docs/firefox-support.md). + ### Enterprise Package the extension directory (zip) and deploy through your browser’s policy diff --git a/config/managed_schema.json b/config/managed_schema.json index 46a53e75..fa279af6 100644 --- a/config/managed_schema.json +++ b/config/managed_schema.json @@ -13,6 +13,14 @@ "type": "boolean", "default": true }, + "validPageBadgeTimeout": { + "title": "Valid Page Badge Timeout (seconds)", + "description": "Auto-dismiss timeout for the valid page badge in seconds. Set to 0 for no timeout (badge stays visible until manually dismissed).", + "type": "integer", + "minimum": 0, + "maximum": 300, + "default": 5 + }, "enablePageBlocking": { "title": "Page Blocking Enabled", "description": "Enable blocking of malicious pages", diff --git a/docs/README.md b/docs/README.md index 53e37e62..c587033b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,10 +20,12 @@ layout: ## What is Check? -**Check** is an browser extension that provides real-time protection against Microsoft 365 phishing attacks. +**Check** is a browser extension that provides real-time protection against Microsoft 365 phishing attacks. Specifically designed for enterprises and managed service providers, Check uses sophisticated detection algorithms to identify and block malicious login pages before credentials can be stolen by bad actors. +Check is available for **Chrome**, **Microsoft Edge**, and **Firefox** (109+). + The extension integrates seamlessly with existing security workflows, offering centralized management, comprehensive logging, and offers an optional CIPP integration for MSPs managing multiple Microsoft 365 tenants. Check is completely free, open source, and can be delivered to users completely white-label, it is an open source project licensed under AGPL-3. You can contribute to check at [https://github.com/cyberdrain/Check](https://github.com/cyberdrain/Check). @@ -32,6 +34,8 @@ Installing the plugin immediately gives you protection against AITM attacks, and Install for Edge **OR** Install for Chrome +**Firefox users:** See the [Firefox Support](firefox-support.md) guide for installation instructions. + ## Why was Check created? Check was created out of a need to have better protection against AITM attacks. During a CyberDrain brainstorming session CyberDrain's lead dev came up with the idea to create a Chrome extension to protect users: diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 4d42d145..60231546 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,6 +1,7 @@ # Table of contents - [About](README.md) +- [Firefox Support](firefox-support.md) ## Deployment @@ -10,6 +11,7 @@ - [Domain Deployment](deployment/chrome-edge-deployment-instructions/windows/domain-deployment.md) - [RMM Deployment](deployment/chrome-edge-deployment-instructions/windows/rmm-deployment.md) - [MacOS](deployment/chrome-edge-deployment-instructions/macos.md) +- [Firefox Deployment](deployment/firefox-deployment.md) ## Settings diff --git a/docs/advanced/creating-detection-rules.md b/docs/advanced/creating-detection-rules.md index d247c0dc..08c39349 100644 --- a/docs/advanced/creating-detection-rules.md +++ b/docs/advanced/creating-detection-rules.md @@ -4,7 +4,7 @@ The extension uses a rule-driven architecture where all detection logic is defin * **Trusted domain patterns** - Microsoft domains that are always trusted * **Exclusion system** - Domains that should never be scanned -* **Phishing indicators** - Patterns that detect malicious content +* **Phishing indicators** - Patterns that detect malicious content (supports both regex and code-driven logic) * **Detection requirements** - Elements that identify Microsoft 365 login pages * **Blocking rules** - Conditions that immediately block pages * **Rogue apps detection** - Dynamic detection of known malicious OAuth applications @@ -70,7 +70,16 @@ These domains get immediate trusted status with valid badges: ] ``` -### Indicators +## Phishing Indicators + +The Check extension supports two types of phishing indicators: + +1. **Regex-based indicators** - Traditional pattern matching using regular expressions +2. **Code-driven indicators** - Advanced logic-based detection using structured operations + +### Regex-Based Indicators + +Traditional indicators use regular expressions to match patterns in page content: ```json { @@ -85,6 +94,258 @@ These domains get immediate trusted status with valid badges: } ``` +### Code-Driven Indicators + +Code-driven indicators allow complex detection logic without regex complexity. Set `code_driven: true` and define your logic in the `code_logic` object: + +```json +{ + "id": "phi_example_code_driven", + "code_driven": true, + "code_logic": { + "type": "all_of", + "operations": [ + { + "type": "substring_present", + "values": ["microsoft", "office", "365"] + }, + { + "type": "substring_present", + "values": ["password", "login"] + } + ] + }, + "severity": "high", + "description": "Microsoft branding with credential fields", + "action": "warn", + "category": "credential_harvesting", + "confidence": 0.8 +} +``` + +#### Code-Driven Logic Types + +**1. `substring_present`** - Check if substrings are in the page + +```json +{ + "type": "substring_present", + "values": ["microsoft", "office", "365"] +} +``` + +**2. `substring_count`** - Require minimum occurrences + +```json +{ + "type": "substring_count", + "substrings": ["verify", "urgent", "suspended"], + "min_count": 2 +} +``` + +**3. `substring_proximity`** - Words must appear near each other + +```json +{ + "type": "substring_proximity", + "word1": "urgent", + "word2": "action", + "max_distance": 500 +} +``` + +**4. `multi_proximity`** - Check multiple word pairs + +```json +{ + "type": "multi_proximity", + "pairs": [ + {"words": ["verify", "account"], "max_distance": 50}, + {"words": ["suspended", "365"], "max_distance": 50}, + {"words": ["secure", "microsoft"], "max_distance": 50} + ] +} +``` + +**5. `all_of`** - All conditions must match + +```json +{ + "type": "all_of", + "operations": [ + { + "type": "substring_present", + "values": ["microsoft"] + }, + { + "type": "substring_present", + "values": ["password"] + } + ] +} +``` + +**6. `any_of`** - At least one condition must match + +```json +{ + "type": "any_of", + "operations": [ + { + "type": "substring_proximity", + "word1": "urgent", + "word2": "action", + "max_distance": 500 + }, + { + "type": "substring_proximity", + "word1": "immediate", + "word2": "attention", + "max_distance": 500 + } + ] +} +``` + +**7. `has_but_not`** - Require some keywords, prohibit others + +```json +{ + "type": "has_but_not", + "required": ["microsoft", "login"], + "prohibited": [ + "sign in with microsoft", + "sso", + "oauth", + "third party auth" + ] +} +``` + +**8. `pattern_count`** - Count regex pattern matches + +```json +{ + "type": "pattern_count", + "patterns": ["]*action"], + "flags": "i", + "min_count": 1 +} +``` + +**9. `obfuscation_check`** - Detect code obfuscation + +```json +{ + "type": "obfuscation_check", + "indicators": [ + "eval(atob(", + "Function(atob(", + "String.fromCharCode", + "setInterval(eval(" + ], + "min_matches": 2 +} +``` + +**10. `form_action_check`** - Validate form submission targets + +```json +{ + "type": "form_action_check", + "required_domains": ["login.microsoftonline.com"] +} +``` + +**11. `resource_from_domain`** - Verify resource origins + +```json +{ + "type": "resource_from_domain", + "resource_type": "customcss", + "allowed_domains": ["aadcdn.msftauthimages.net"], + "invert": true +} +``` + +**12. `substring_or_regex`** - Fast substring check with regex fallback + +```json +{ + "type": "substring_or_regex", + "substrings": ["atob(", "unescape(", "eval("], + "regex": "(?:var|let|const)\\s+\\w+\\s*=\\s*(?:atob|unescape)\\([^)]+\\)", + "flags": "i" +} +``` + +#### Complete Code-Driven Example + +Here's a real-world example from the detection rules that detects Microsoft branding combined with urgency tactics: + +```json +{ + "id": "phi_004", + "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", + "category": "social_engineering", + "confidence": 0.65 +} +``` + +This rule triggers when: +1. Any urgency phrase pair is detected (urgent+action, immediate+attention, or act+now) +2. AND Microsoft branding keywords are present + +#### When to Use Code-Driven vs Regex + +**Use Code-Driven When:** +- You need to check multiple conditions (AND/OR logic) +- Word proximity matters +- You want to exclude certain contexts (allowlist patterns) +- Performance is important (substring checks are faster than complex regex) +- Rules are easier to maintain and understand + +**Use Regex When:** +- You have a simple, single pattern to match +- You need complex character matching +- The pattern is already well-tested as regex + ### Pattern Properties * **id**: Unique identifier for the rule diff --git a/docs/deployment/chrome-edge-deployment-instructions/README.md b/docs/deployment/chrome-edge-deployment-instructions/README.md index 94ca7894..809db3a9 100644 --- a/docs/deployment/chrome-edge-deployment-instructions/README.md +++ b/docs/deployment/chrome-edge-deployment-instructions/README.md @@ -1,13 +1,15 @@ --- description: >- This page will outline the various ways that you can deploy Check to your - clients' environments + clients' environments across Chrome, Edge, and Firefox icon: bolt --- -# Chrome/Edge Deployment Instructions +# Deployment Instructions -Check is available for +Check is available for **Chrome**, **Microsoft Edge**, and **Firefox** with deployment guides for each browser. + +## Chrome/Edge Deployment {% content-ref url="windows/" %} [windows](windows/) @@ -16,3 +18,9 @@ Check is available for {% content-ref url="macos.md" %} [macos.md](macos.md) {% endcontent-ref %} + +## Firefox Deployment + +{% content-ref url="../firefox-deployment.md" %} +[firefox-deployment.md](../firefox-deployment.md) +{% endcontent-ref %} diff --git a/docs/deployment/firefox-deployment.md b/docs/deployment/firefox-deployment.md new file mode 100644 index 00000000..83fe5706 --- /dev/null +++ b/docs/deployment/firefox-deployment.md @@ -0,0 +1,505 @@ +# Firefox Deployment + +This guide covers deploying Check to Firefox across different platforms using enterprise policies. + +## Overview + +Firefox supports centralized extension management through the `policies.json` file. This method works across Windows, macOS, and Linux, making it ideal for enterprise deployments. + +## Extension ID + +The Check extension for Firefox uses the ID: **`check@cyberdrain.com`** + +## Quick Reference + +| Platform | Policy File Location | +|----------|---------------------| +| Windows | `%ProgramFiles%\Mozilla Firefox\distribution\policies.json` | +| macOS | `/Applications/Firefox.app/Contents/Resources/distribution/policies.json` | +| Linux (system) | `/etc/firefox/policies/policies.json` | +| Linux (app) | `/usr/lib/firefox/distribution/policies.json` | + +## Prerequisites + +Before deploying Check to Firefox: + +1. **Firefox 109 or later** installed on target systems +2. **Administrator/root access** for system-wide deployment +3. **Signed extension package** (.xpi file) for production deployment +4. **Template policies.json** from `enterprise/firefox/policies.json` in the repository + +## Deployment Steps + +### 1. Prepare the Extension Package + +For production deployment, you need a signed .xpi file: + +#### Option A: Mozilla Add-ons Signing (Recommended) + +1. Build the Firefox version: + ```bash + npm run build:firefox + ``` + +2. Package the extension: + ```bash + zip -r check-firefox.zip . \ + -x ".*" \ + -x "node_modules/*" \ + -x "tests/*" \ + -x "*.md" \ + -x "manifest.chrome.json" + ``` + +3. Submit to [addons.mozilla.org](https://addons.mozilla.org) for signing +4. Download the signed .xpi file +5. Host on your internal server or use Mozilla's CDN + +#### Option B: Development Installation + +For testing or development: +- Use temporary add-on installation (no signing required) +- Enable unsigned extensions in Firefox developer edition +- Not recommended for production deployments + +### 2. Configure policies.json + +Create or modify `policies.json` based on the template in `enterprise/firefox/policies.json`: + +```json +{ + "policies": { + "Extensions": { + "Install": [ + "https://your-server.com/path/to/check-extension.xpi" + ], + "Locked": [ + "check@cyberdrain.com" + ] + }, + "ExtensionSettings": { + "check@cyberdrain.com": { + "installation_mode": "force_installed", + "install_url": "https://your-server.com/path/to/check-extension.xpi", + "default_area": "navbar" + } + }, + "3rdparty": { + "Extensions": { + "check@cyberdrain.com": { + "showNotifications": true, + "enableValidPageBadge": true, + "enablePageBlocking": true, + "enableCippReporting": false, + "cippServerUrl": "", + "cippTenantId": "", + "customRulesUrl": "https://raw.githubusercontent.com/CyberDrain/Check/refs/heads/main/rules/detection-rules.json", + "updateInterval": 24, + "urlAllowlist": [], + "enableDebugLogging": false, + "customBranding": { + "companyName": "Your Company Name", + "companyURL": "https://yourcompany.com", + "productName": "Security Extension", + "supportEmail": "support@yourcompany.com", + "primaryColor": "#F77F00", + "logoUrl": "https://yourcompany.com/logo.png" + }, + "genericWebhook": { + "enabled": false, + "url": "https://webhook.example.com/endpoint", + "events": [ + "detection_alert", + "page_blocked", + "threat_detected" + ] + } + } + } + } + } +} +``` + +### 3. Deploy by Platform + +{% tabs %} +{% tab title="Windows" %} +#### Windows Deployment + +**Manual Deployment:** + +1. Create the distribution folder if it doesn't exist: + ```powershell + New-Item -ItemType Directory -Force -Path "$env:ProgramFiles\Mozilla Firefox\distribution" + ``` + +2. Copy your configured `policies.json`: + ```powershell + Copy-Item policies.json "$env:ProgramFiles\Mozilla Firefox\distribution\policies.json" + ``` + +3. Restart Firefox on all systems + +**Group Policy Deployment:** + +Firefox also supports Windows GPO. For organizations using Active Directory: + +1. Download Firefox ADMX templates from Mozilla +2. Import into Group Policy Management +3. Configure extension policies through GPO +4. Link to appropriate OUs + +**Intune Deployment:** + +Deploy via Microsoft Intune using a PowerShell script: + +```powershell +$policiesPath = "$env:ProgramFiles\Mozilla Firefox\distribution" +$policiesFile = "$policiesPath\policies.json" + +# Create directory if needed +if (!(Test-Path $policiesPath)) { + New-Item -ItemType Directory -Force -Path $policiesPath +} + +# Download or embed policies.json +$policiesJson = @' +{ + "policies": { + // Your policies here + } +} +'@ + +# Write policies file +$policiesJson | Out-File -FilePath $policiesFile -Encoding UTF8 + +Write-Output "Firefox policies deployed successfully" +``` +{% endtab %} + +{% tab title="macOS" %} +#### macOS Deployment + +**Manual Deployment:** + +1. Create the distribution folder: + ```bash + sudo mkdir -p "/Applications/Firefox.app/Contents/Resources/distribution" + ``` + +2. Copy your configured `policies.json`: + ```bash + sudo cp policies.json "/Applications/Firefox.app/Contents/Resources/distribution/policies.json" + ``` + +3. Set appropriate permissions: + ```bash + sudo chmod 644 "/Applications/Firefox.app/Contents/Resources/distribution/policies.json" + sudo chown root:wheel "/Applications/Firefox.app/Contents/Resources/distribution/policies.json" + ``` + +**MDM Deployment (Jamf, Intune, etc.):** + +Deploy using a script payload: + +```bash +#!/bin/bash + +POLICIES_DIR="/Applications/Firefox.app/Contents/Resources/distribution" +POLICIES_FILE="$POLICIES_DIR/policies.json" + +# Create directory +mkdir -p "$POLICIES_DIR" + +# Write policies (embed your policies.json content) +cat > "$POLICIES_FILE" << 'EOF' +{ + "policies": { + // Your policies here + } +} +EOF + +# Set permissions +chmod 644 "$POLICIES_FILE" +chown root:wheel "$POLICIES_FILE" + +echo "Firefox policies deployed successfully" +``` + +**Configuration Profile (Alternative):** + +Some MDM systems support Firefox configuration profiles. Check your MDM documentation for Firefox-specific configuration options. +{% endtab %} + +{% tab title="Linux" %} +#### Linux Deployment + +**System-Wide Deployment:** + +1. Create the policies directory: + ```bash + sudo mkdir -p /etc/firefox/policies + ``` + +2. Copy your configured `policies.json`: + ```bash + sudo cp policies.json /etc/firefox/policies/policies.json + ``` + +3. Set proper permissions: + ```bash + sudo chmod 644 /etc/firefox/policies/policies.json + ``` + +**Distribution-Specific Locations:** + +Different Linux distributions may use different paths: + +- **Debian/Ubuntu**: `/etc/firefox/policies/policies.json` +- **RHEL/CentOS/Fedora**: `/usr/lib64/firefox/distribution/policies.json` +- **SUSE/openSUSE**: `/usr/lib/firefox/distribution/policies.json` +- **Snap package**: Policies not supported via traditional methods + +**Automated Deployment:** + +Using Ansible: +```yaml +- name: Deploy Firefox Check Extension Policy + copy: + src: policies.json + dest: /etc/firefox/policies/policies.json + owner: root + group: root + mode: '0644' + notify: restart firefox +``` + +Using Puppet: +```puppet +file { '/etc/firefox/policies': + ensure => directory, + mode => '0755', +} + +file { '/etc/firefox/policies/policies.json': + ensure => file, + source => 'puppet:///modules/firefox/policies.json', + mode => '0644', + require => File['/etc/firefox/policies'], +} +``` +{% endtab %} +{% endtabs %} + +## Configuration Options + +All Check configuration options are available through the `3rdparty.Extensions` section of policies.json. + +### Security Settings + +```json +{ + "showNotifications": true, // Display detection notifications + "enableValidPageBadge": true, // Show badge on legitimate sites + "enablePageBlocking": true, // Block confirmed phishing sites + "enableDebugLogging": false // Enable debug logging +} +``` + +### CIPP Integration + +```json +{ + "enableCippReporting": true, + "cippServerUrl": "https://cipp.yourcompany.com", + "cippTenantId": "your-tenant-id" +} +``` + +### Detection Rules + +```json +{ + "customRulesUrl": "https://your-server.com/detection-rules.json", + "updateInterval": 24, // Hours between rule updates + "urlAllowlist": [ // Domains to never flag + "trusted-domain.com" + ] +} +``` + +### Custom Branding + +```json +{ + "customBranding": { + "companyName": "Your Company", + "productName": "Security Extension", + "supportEmail": "support@yourcompany.com", + "primaryColor": "#F77F00", + "logoUrl": "https://yourcompany.com/logo.png" + } +} +``` + +### Generic Webhook + +Configure a webhook to receive detection events: + +```json +{ + "genericWebhook": { + "enabled": true, + "url": "https://webhook.example.com/endpoint", + "events": [ + "detection_alert", + "page_blocked", + "threat_detected", + "rogue_app_detected" + ] + } +} +``` + +**Available Event Types:** +- `detection_alert` - General phishing detection events +- `false_positive_report` - User-submitted false positive reports +- `page_blocked` - Page blocking events +- `rogue_app_detected` - OAuth rogue application detection +- `threat_detected` - General threat detection events +- `validation_event` - Legitimate page validation events + +For webhook payload schema and implementation details, see the [Webhook Documentation](../webhooks.md). + +For all available options, see `config/managed_schema.json` in the repository. + +## Verification + +### Check Policy Application + +After deployment, verify policies are applied: + +1. Open Firefox +2. Navigate to `about:policies` +3. Verify that your policies appear under "Active Policies" +4. Check for any error messages + +### Verify Extension Installation + +1. Navigate to `about:addons` +2. Confirm Check extension is installed +3. Verify it shows as "Managed by your organization" +4. Check that users cannot disable or remove it (if locked) + +### Test Functionality + +1. Visit a test phishing site +2. Verify the extension detects and blocks/warns appropriately +3. Check the extension popup for status +4. Test branding appears correctly + +## Updating the Extension + +### Update Process + +When a new version is released: + +1. Build and sign the new version +2. Upload to your distribution server +3. Update the `install_url` in policies.json if the URL changed +4. Firefox will automatically update the extension based on the update manifest + +### Force Immediate Update + +To force an immediate update: + +1. Remove the extension from `policies.json` +2. Push the updated policy (Firefox will remove the extension) +3. Re-add the extension with the new URL +4. Push the updated policy again + +## Troubleshooting + +### Policies Not Applied + +**Check these items:** + +1. **File location**: Verify policies.json is in the correct path for your OS +2. **File permissions**: Must be readable by Firefox (644 recommended) +3. **JSON syntax**: Validate your JSON at jsonlint.com +4. **Firefox restart**: Policies apply on Firefox startup +5. **about:policies**: Check for error messages + +### Extension Not Installing + +**Common causes:** + +1. **Unsigned extension**: Production deployments require signed .xpi +2. **Unreachable URL**: Verify the install_url is accessible +3. **Network restrictions**: Check firewall/proxy settings +4. **Firefox version**: Ensure Firefox 109+ + +### Configuration Not Working + +**Verify:** + +1. Extension ID matches: `check@cyberdrain.com` +2. Settings are in the `3rdparty.Extensions` section +3. JSON formatting is correct +4. Firefox was restarted after policy deployment + +### Users Can Still Disable Extension + +**Ensure:** + +1. Extension is in the `Locked` array +2. `installation_mode` is set to `force_installed` +3. Policies.json was properly deployed +4. Firefox has been restarted since deployment + +## Removal + +To remove the Check extension: + +### Option 1: Update policies.json + +Remove the extension from Install and ExtensionSettings: + +```json +{ + "policies": { + "Extensions": { + "Uninstall": ["check@cyberdrain.com"] + } + } +} +``` + +### Option 2: Delete policies.json + +Remove the entire policies file (will remove all managed extensions and policies). + +## Best Practices + +1. **Test First**: Deploy to a pilot group before organization-wide rollout +2. **Version Control**: Keep policies.json in version control +3. **Monitor Logs**: Check Firefox logs during initial deployment +4. **Document Changes**: Record configuration changes and reasons +5. **Update Regularly**: Keep the extension updated for latest protections +6. **Validate JSON**: Always validate policies.json syntax before deployment + +## Support Resources + +- **Template**: `enterprise/firefox/policies.json` +- **Schema**: `config/managed_schema.json` +- **Firefox Policies**: [Mozilla Policy Documentation](https://github.com/mozilla/policy-templates) +- **General Support**: See [Firefox Support](../firefox-support.md) + +## Additional Resources + +- [Firefox Enterprise Support](https://support.mozilla.org/en-US/products/firefox-enterprise) +- [Firefox Policy Templates](https://github.com/mozilla/policy-templates) +- [Enterprise Information for IT](https://support.mozilla.org/en-US/kb/enterprise-information-it) diff --git a/docs/firefox-support.md b/docs/firefox-support.md new file mode 100644 index 00000000..dedbc3dc --- /dev/null +++ b/docs/firefox-support.md @@ -0,0 +1,303 @@ +# Firefox Support + +Check fully supports Firefox 109+ with all the same phishing protection features available in Chrome and Edge. This page covers installation, deployment, and Firefox-specific considerations. + +## Quick Start + +### Manual Installation (Development/Testing) + +1. Clone or download the Check repository +2. Run `npm run build:firefox` to configure the extension for Firefox +3. Open Firefox and navigate to `about:debugging#/runtime/this-firefox` +4. Click **Load Temporary Add-on** +5. Select the `manifest.json` file from the repository directory + +{% hint style="info" %} +Temporary add-ons are removed when Firefox restarts. For permanent installation, see the Enterprise Deployment section below. +{% endhint %} + +### Switching Back to Chrome/Edge + +If you need to switch back to Chrome or Edge after building for Firefox: + +```bash +npm run build:chrome +``` + +Alternatively, restore the original manifest from version control: + +```bash +git checkout manifest.json +``` + +## Firefox-Specific Differences + +The Firefox version of Check includes several technical differences from the Chrome/Edge version to ensure compatibility: + +### Manifest Differences +- **Background Scripts**: Uses `background.scripts` instead of `service_worker` +- **Content Scripts**: Excludes `file:///` protocol (not supported in Firefox) +- **Options Page**: Uses `options_ui` instead of `options_page` +- **Browser Settings**: Includes `browser_specific_settings` with Gecko ID `check@cyberdrain.com` +- **Permissions**: Excludes `identity.email` permission (not needed in Firefox) + +### Cross-Browser Compatibility + +Check uses a browser polyfill (`scripts/browser-polyfill.js`) to handle API differences between Chrome and Firefox automatically. This ensures that: +- Extension APIs work consistently across browsers +- Code can be written once and work everywhere +- Updates maintain compatibility with all supported browsers + +## Enterprise Deployment + +### Prerequisites + +- Firefox 109 or later +- Administrator access for system-wide deployment +- Extension signed by Mozilla (for permanent installation) + +### Deployment Methods + +Firefox supports enterprise deployment through the `policies.json` file. This method works on Windows, macOS, and Linux. + +#### Windows Deployment + +1. Create or edit the policies file at: + ``` + %ProgramFiles%\Mozilla Firefox\distribution\policies.json + ``` + +2. Use the template from `enterprise/firefox/policies.json` in the repository + +3. Update the `install_url` to point to your signed .xpi file: + ```json + { + "policies": { + "Extensions": { + "Install": ["https://your-server.com/check-extension.xpi"] + } + } + } + ``` + +#### macOS/Linux Deployment + +1. Create the policies file at: + - **macOS**: `/Applications/Firefox.app/Contents/Resources/distribution/policies.json` + - **Linux**: `/etc/firefox/policies/policies.json` or `/usr/lib/firefox/distribution/policies.json` + +2. Use the template from `enterprise/firefox/policies.json` + +3. Set proper permissions: + ```bash + sudo chmod 644 /path/to/policies.json + ``` + +### Extension Configuration + +Firefox uses the `3rdparty` section in `policies.json` to configure extension settings: + +```json +{ + "policies": { + "3rdparty": { + "Extensions": { + "check@cyberdrain.com": { + "showNotifications": true, + "enableValidPageBadge": true, + "enablePageBlocking": true, + "enableCippReporting": false, + "cippServerUrl": "", + "cippTenantId": "", + "customRulesUrl": "https://raw.githubusercontent.com/CyberDrain/Check/refs/heads/main/rules/detection-rules.json", + "updateInterval": 24, + "urlAllowlist": [], + "enableDebugLogging": false, + "customBranding": { + "companyName": "", + "productName": "", + "supportEmail": "", + "primaryColor": "#F77F00", + "logoUrl": "" + }, + "genericWebhook": { + "enabled": false, + "url": "https://webhook.example.com/endpoint", + "events": [ + "detection_alert", + "page_blocked", + "threat_detected" + ] + } + } + } + } + } +} +``` + +See the full configuration schema in `config/managed_schema.json` for all available settings. + +For webhook configuration and payload details, see the [Webhook Documentation](webhooks.md). + +### Force Installation + +To force-install Check and prevent users from disabling it: + +```json +{ + "policies": { + "Extensions": { + "Install": ["https://your-server.com/check-extension.xpi"], + "Locked": ["check@cyberdrain.com"] + }, + "ExtensionSettings": { + "check@cyberdrain.com": { + "installation_mode": "force_installed", + "install_url": "https://your-server.com/check-extension.xpi", + "default_area": "navbar" + } + } + } +} +``` + +## Signing and Distribution + +### Development Signing + +For testing purposes, you can use Firefox's developer mode: +1. Navigate to `about:config` +2. Set `xpinstall.signatures.required` to `false` +3. Load the extension as a temporary add-on + +{% hint style="warning" %} +Disabling signature verification is only recommended for development and testing environments. +{% endhint %} + +### Production Signing + +For production deployment, you need to sign the extension with Mozilla: + +1. Create a Mozilla Add-ons account at [addons.mozilla.org](https://addons.mozilla.org) +2. Package your extension: + ```bash + npm run build:firefox + zip -r check-firefox.zip . -x ".*" "node_modules/*" "tests/*" "*.md" "manifest.chrome.json" + ``` +3. Submit to Mozilla for signing (unlisted distribution for enterprise) +4. Download the signed .xpi file +5. Host the .xpi file on your server or use Mozilla's CDN + +### Self-Distribution + +For enterprise environments, you can self-distribute the signed .xpi: +1. Host the .xpi file on an internal web server +2. Configure `policies.json` with your internal URL +3. Deploy the policies file to managed devices + +## Testing Firefox Extension + +### Manual Testing + +1. Load the extension using the Quick Start instructions +2. Open the test page: `test-extension-loading.html` +3. Verify that all components load correctly: + - Background scripts initialize + - Content scripts inject on pages + - Popup and options pages display correctly + +### Testing Detection Rules + +1. Visit known phishing test sites (use safe testing environments) +2. Verify that warnings and blocks display correctly +3. Check the extension popup for detection status +4. Review browser console for any errors + +### Cross-Browser Testing + +When contributing or making changes, always test in both Chrome/Edge and Firefox: + +1. Test in Chrome/Edge: + ```bash + npm run build:chrome + # Load in Chrome + ``` + +2. Test in Firefox: + ```bash + npm run build:firefox + # Load in Firefox + ``` + +3. Verify consistent behavior across browsers +4. Check for Firefox-specific console errors or warnings + +## Troubleshooting + +### Extension Not Loading + +**Problem**: Extension doesn't load or shows errors + +**Solutions**: +- Ensure you ran `npm run build:firefox` before loading +- Check that Firefox version is 109 or later +- Look for errors in Browser Console (Ctrl+Shift+J) +- Verify manifest.json has Firefox-specific structure + +### Background Scripts Not Working + +**Problem**: Background functionality fails in Firefox + +**Solutions**: +- Firefox uses `background.scripts` not `service_worker` +- Verify the build script ran successfully +- Check for module loading errors in the Browser Console + +### Policies Not Applied + +**Problem**: Enterprise policies not taking effect + +**Solutions**: +- Verify policies.json is in the correct location for your OS +- Check file permissions (must be readable by Firefox) +- Restart Firefox after adding/modifying policies +- Use `about:policies` to verify policy application +- Check JSON syntax in policies.json + +### Extension Removed on Restart + +**Problem**: Extension disappears when Firefox restarts + +**Solutions**: +- Temporary add-ons are removed on restart - this is expected +- For permanent installation, use enterprise deployment with signed .xpi +- Alternatively, sign the extension through Mozilla's process + +### Content Scripts Not Injecting + +**Problem**: Content scripts don't run on web pages + +**Solutions**: +- Firefox doesn't support `file:///` protocol in content scripts +- Ensure you're testing on `http://` or `https://` URLs +- Check content script permissions in manifest + +## Firefox Extension ID + +The Firefox extension uses the ID: `check@cyberdrain.com` + +This ID is configured in the `browser_specific_settings` section of `manifest.firefox.json` and is required for: +- Enterprise policy management +- Extension configuration +- Add-on signing and distribution + +## Support + +For Firefox-specific issues: +- Check the [Common Issues](troubleshooting/common-issues.md) guide +- Review Firefox Browser Console for errors +- Verify you're using Firefox 109 or later +- Ensure the extension was built for Firefox using `npm run build:firefox` + +For general extension support, see the main [README](../README.md) and [CONTRIBUTING](../CONTRIBUTING.md) guides. diff --git a/docs/settings/branding.md b/docs/settings/branding.md index f635c235..f6860a38 100644 --- a/docs/settings/branding.md +++ b/docs/settings/branding.md @@ -8,6 +8,15 @@ The Branding section lets you customize how Check looks, especially useful for o Most individual users can skip this section unless they want to personalize the extension. {% endhint %} +## Overview + +All user-facing components (suspicious login banner, blocked page, extension popup, and options page) use the same branding configuration. Your custom branding will be displayed consistently across: + +- **Suspicious Login Banner** - Warning banner shown on potentially malicious sites +- **Blocked Page** - Full-page block screen for confirmed threats +- **Extension Popup** - Extension icon popup +- **Options Page** - Extension settings page + ## Company Information {% hint style="warning" %} @@ -16,15 +25,19 @@ Most individual users can skip this section unless they want to personalize the If some settings do not appear on your version, it means your organization's IT department has set these for you. This is normal in business environments - your IT team wants to make sure everyone has the same security settings. You will also see text indicating that the extension is being managed by policy. {% endhint %} -1. **Company Name** - Enter your organization's name. This appears in the extension interface and blocked page messages. -2. **Company URL** - Your company website URL (e.g., `https://yourcompany.com`). Used in extension branding and contact information. +### Branding Properties + +You can customize the following properties: + +1. **Company Name** - Enter your organization's name. This appears in the extension interface and blocked page messages (displayed as "Protected by [Company Name]"). +2. **Company URL** - Your company website URL (e.g., `https://yourcompany.com`). Used in extension branding and contact information. *(Firefox: required, Chrome/Edge: optional)* 3. **Product Name** - What you want to call the extension (like "Contoso Security" instead of "Check"). This replaces the default "Check" branding throughout the interface. 4. **Support Email** - Where users should go for help. This email address is used in the "Contact Admin" button when phishing sites are blocked. ## Visual Customization -1. **Primary Color** - Choose a color that matches your brand. This color is applied to buttons, headers, and other interface elements throughout the extension. -2. **Logo URL** - Link to your company logo. This replaces the default Check logo in the extension popup, options page, and blocked page warnings. +1. **Primary Color** - Choose a color that matches your brand (hex format, e.g., `#FF5733`). This color is applied to buttons, headers, and other interface elements throughout the extension. +2. **Logo URL** - Link to your company logo or local path (e.g., `https://cdn.example.com/logo.png` or `images/custom-logo.png`). This replaces the default Check logo in the extension popup, options page, and blocked page warnings. ## Live Preview @@ -34,12 +47,141 @@ The branding preview shows you exactly how your customizations will appear to us * How the primary color affects buttons and interface elements * The overall visual appearance users will see +## Configuration Methods + +### Method 1: Manual Configuration (Options Page) + +**Works with:** Chrome, Edge, Firefox + +1. Open the extension's Options page +2. Navigate to the "Branding" section +3. Fill in your branding information: + - Company Name + - Logo (upload or provide URL) + - Primary Color + - Support Email +4. Click "Save" + +Your branding will be immediately applied to all components. + +### Method 2: Group Policy (GPO) - Chrome & Edge + +For enterprise deployments using Windows Group Policy: + +1. Create a new GPO or edit an existing one +2. Navigate to: `Computer Configuration > Administrative Templates > Google Chrome > Extensions` +3. Add a policy for the Check extension with the following structure: + +```json +{ + "customBranding": { + "companyName": "Your Company", + "logoUrl": "https://example.com/logo.png", + "primaryColor": "#FF5733", + "supportEmail": "security@example.com" + } +} +``` + +4. Apply the policy to target computers +5. The extension will automatically use the enterprise branding on managed devices + +### Method 3: Firefox Policies (policies.json) + +**Works with:** Firefox only + +For Firefox deployments, configure branding through the `policies.json` file: + +1. Locate or create the policies file: + - **Windows:** `%ProgramFiles%\Mozilla Firefox\distribution\policies.json` + - **macOS:** `/Applications/Firefox.app/Contents/Resources/distribution/policies.json` + - **Linux:** `/etc/firefox/policies/policies.json` + +2. Add the branding configuration under `3rdparty.Extensions`: + +```json +{ + "policies": { + "3rdparty": { + "Extensions": { + "check@cyberdrain.com": { + "customBranding": { + "companyName": "Your Company", + "companyURL": "https://yourcompany.com", + "productName": "Security Extension", + "supportEmail": "security@example.com", + "primaryColor": "#FF5733", + "logoUrl": "https://example.com/logo.png" + } + } + } + } + } +} +``` + +3. Save the file and restart Firefox + +**Note:** The Firefox extension ID is `check@cyberdrain.com` + +### Method 4: Microsoft Intune - Chrome & Edge + +For organizations using Microsoft Intune with Chrome/Edge: + +1. Create a new Configuration Profile +2. Select "Custom" configuration +3. Add the branding configuration as a JSON payload: + +```json +{ + "customBranding": { + "companyName": "Your Company", + "logoUrl": "https://example.com/logo.png", + "primaryColor": "#FF5733", + "supportEmail": "security@example.com" + } +} +``` + +4. Assign the profile to user or device groups +5. Branding will be applied on enrolled devices + +### Method 5: Chrome Enterprise Policy + +For Chrome Enterprise customers: + +1. Access the Google Admin Console +2. Navigate to: `Devices > Chrome > Apps & Extensions` +3. Select the Check extension +4. Add the branding configuration under "Policy for extensions" +5. Save and publish the policy + +### Method 6: Windows Registry (Advanced) - Chrome & Edge + +For direct registry configuration with Chrome/Edge: + +1. Open Registry Editor +2. Navigate to: `HKLM\Software\Policies\Google\Chrome\3rdparty\extensions\[extension-id]` +3. Create a new key named `customBranding` +4. Add string values for each branding property +5. Restart the browser + +## Configuration Priority + +When multiple configuration methods are used, they are applied in this order (highest to lowest priority): + +1. **Enterprise Policy** (GPO/Intune/Chrome Enterprise/Firefox Policies) +2. **Manual Configuration** (Options page) +3. **Default Configuration** (Built-in defaults) + +Enterprise policies always take precedence over manual settings. + ## Logo Requirements and Tips ### **Technical requirements:** * Format: PNG, JPG, or SVG -* Size: 48x48 pixels recommended (maximum 128x128) +* Size: 48x48 pixels recommended (maximum 128x128, recommended 200x200px or smaller for enterprise deployments) * Must be accessible via HTTPS URL ### **Design tips:** @@ -54,6 +196,19 @@ The branding preview shows you exactly how your customizations will appear to us * Cloud storage: Upload to Google Drive, Dropbox, etc. and get a public link * Image hosting: Use services like Imgur or similar +## Browser-Specific Notes + +### Firefox +- Uses extension ID: `check@cyberdrain.com` +- Configuration is managed through `policies.json` file +- Supports additional `companyURL` property +- Policies file location varies by operating system + +### Chrome & Edge +- Configuration through GPO, Intune, or Chrome Enterprise Policy +- Uses Windows Registry for advanced configurations +- Supports standard Chrome extension policy format + ## Troubleshooting Branding Issues ### **Logo not showing:** @@ -62,6 +217,9 @@ The branding preview shows you exactly how your customizations will appear to us 2. Try opening the logo URL in a new browser tab 3. Make sure the URL starts with `https://` 4. Verify the image file isn't too large +5. Verify logo URLs are publicly accessible (if using external URL) +6. Check image format (PNG, JPG, SVG supported) +7. Ensure image size is reasonable ### **Colors not applying:** @@ -75,7 +233,19 @@ The branding preview shows you exactly how your customizations will appear to us 2. Refresh the settings page 3. Clear your browser cache if problems persist -## Real-World Branding Examples +### **Branding Not Appearing** +- Verify the configuration is saved correctly +- Check browser console for errors +- Ensure logo URLs are accessible +- Restart the browser after configuration changes + +### **Enterprise Policy Not Working** +- Verify the policy is applied to the correct organizational unit +- Check that the extension ID matches your deployment +- Allow 15-30 minutes for policy propagation +- Run `gpupdate /force` on Windows to force policy refresh + +## Example Configurations ### **Example 1: Small Business Setup** @@ -96,3 +266,62 @@ Support Email: cybersecurity@globalmfg.com Primary Color: #c41e3a (corporate red) Logo URL: https://assets.globalmfg.com/security/gmi-logo-48.png ``` + +### **Example 3: Basic Branding (Chrome/Edge)** + +```json +{ + "customBranding": { + "companyName": "Acme Corp", + "primaryColor": "#00AA00" + } +} +``` + +### **Example 4: Full Branding (Chrome/Edge)** + +```json +{ + "customBranding": { + "companyName": "Contoso Corporation", + "productName": "Contoso Defender", + "logoUrl": "https://contoso.com/assets/logo.png", + "primaryColor": "#0078D4", + "supportEmail": "security@contoso.com" + } +} +``` + +### **Example 5: Firefox Policy Example** + +```json +{ + "policies": { + "3rdparty": { + "Extensions": { + "check@cyberdrain.com": { + "customBranding": { + "companyName": "Contoso Corporation", + "companyURL": "https://contoso.com", + "productName": "Contoso Defender", + "logoUrl": "https://contoso.com/assets/logo.png", + "primaryColor": "#0078D4", + "supportEmail": "security@contoso.com" + } + } + } + } + } +} +``` + +## Additional Resources + +### Firefox-Specific Documentation +- [Firefox Support Guide](../firefox-support.md) +- [Firefox Deployment Guide](../deployment/firefox-deployment.md) +- Template: `enterprise/firefox/policies.json` + +### Chrome/Edge Documentation +- [Chrome/Edge Deployment](../deployment/chrome-edge-deployment-instructions/README.md) +- Schema: `config/managed_schema.json` \ No newline at end of file diff --git a/docs/settings/general.md b/docs/settings/general.md index de261b56..59f682e8 100644 --- a/docs/settings/general.md +++ b/docs/settings/general.md @@ -171,6 +171,16 @@ When Check blocks a dangerous website or finds something suspicious, it can show This adds a small green checkmark to real Microsoft login pages. This feature is optional. +### **Valid Page Badge Timeout** + +This setting controls how long the "Verified Microsoft Domain" badge stays visible on legitimate Microsoft login pages before automatically dismissing. + +- **Set to 0**: Badge stays visible until you manually dismiss it (no timeout) +- **Set to 1-300 seconds**: Badge automatically disappears after the specified number of seconds +- **Default**: 5 seconds + +This allows you to customize the badge experience based on your preferences. If you want to see the badge every time you visit a Microsoft login page, set it to 0. If you prefer it to disappear quickly, use a smaller number like 3-5 seconds. + {% hint style="warning" %} #### What if Settings Are Not Visible? diff --git a/enterprise/Deploy-Windows-Chrome-and-Edge.ps1 b/enterprise/Deploy-Windows-Chrome-and-Edge.ps1 index 25af80b4..b886eb8a 100644 --- a/enterprise/Deploy-Windows-Chrome-and-Edge.ps1 +++ b/enterprise/Deploy-Windows-Chrome-and-Edge.ps1 @@ -24,6 +24,11 @@ $updateInterval = 24 # This will set the "Update Interval" option in the Detecti $urlAllowlist = @() # This will set the "URL Allowlist" option in the Detection Configuration settings; default is blank; if you want to add multiple URLs, add them as a comma-separated list within the brackets (e.g., @("https://example1.com", "https://example2.com")). Supports simple URLs with * wildcard (e.g., https://*.example.com) or advanced regex patterns (e.g., ^https:\/\/(www\.)?example\.com\/.*$). $enableDebugLogging = 0 # 0 = Unchecked, 1 = Checked (Enabled); default is 0; This will set the "Enable Debug Logging" option in the Activity Log settings. +# Generic Webhook Settings +$enableGenericWebhook = 0 # 0 = Disabled, 1 = Enabled; default is 0; This will enable the generic webhook for sending detection events to a custom endpoint. +$webhookUrl = "" # This will set the "Webhook URL" option; default is blank; if you set $enableGenericWebhook to 1, you must set this to a valid URL including the protocol (e.g., https://webhook.example.com/endpoint). +$webhookEvents = @() # This will set the "Event Types" to send to the webhook; default is blank; if you set $enableGenericWebhook to 1, you can specify which events to send. Available events: "detection_alert", "false_positive_report", "page_blocked", "rogue_app_detected", "threat_detected", "validation_event". Example: @("detection_alert", "page_blocked", "threat_detected"). + # Custom Branding Settings $companyName = "CyberDrain" # This will set the "Company Name" option in the Custom Branding settings; default is "CyberDrain". $companyURL = "https://cyberdrain.com" # This will set the Company URL option in the Custom Branding settings; default is "https://cyberdrain.com"; Must include the protocol (e.g., https://). @@ -91,6 +96,32 @@ function Configure-ExtensionSettings { New-ItemProperty -Path $customBrandingKey -Name "primaryColor" -PropertyType String -Value $primaryColor -Force | Out-Null New-ItemProperty -Path $customBrandingKey -Name "logoUrl" -PropertyType String -Value $logoUrl -Force | Out-Null + # Create and configure generic webhook + $genericWebhookKey = "$ManagedStorageKey\genericWebhook" + if (!(Test-Path $genericWebhookKey)) { + New-Item -Path $genericWebhookKey -Force | Out-Null + } + + # Set generic webhook settings + New-ItemProperty -Path $genericWebhookKey -Name "enabled" -PropertyType DWord -Value $enableGenericWebhook -Force | Out-Null + New-ItemProperty -Path $genericWebhookKey -Name "url" -PropertyType String -Value $webhookUrl -Force | Out-Null + + # Create and configure webhook events list + $webhookEventsKey = "$genericWebhookKey\events" + if (!(Test-Path $webhookEventsKey)) { + New-Item -Path $webhookEventsKey -Force | Out-Null + } + + # Clear any existing properties + Remove-ItemProperty -Path $webhookEventsKey -Name * -Force | Out-Null + + # Set webhook events with names starting from 1 + for ($i = 0; $i -lt $webhookEvents.Count; $i++) { + $propertyName = ($i + 1).ToString() + $propertyValue = $webhookEvents[$i] + New-ItemProperty -Path $webhookEventsKey -Name $propertyName -PropertyType String -Value $propertyValue -Force | Out-Null + } + # Create and configure extension settings if (!(Test-Path $ExtensionSettingsKey)) { New-Item -Path $ExtensionSettingsKey -Force | Out-Null diff --git a/enterprise/README.md b/enterprise/README.md index 205bdadb..5ec8974e 100644 --- a/enterprise/README.md +++ b/enterprise/README.md @@ -2,19 +2,32 @@ This folder contains enterprise deployment resources for the Check Microsoft 365 Phishing Protection extension. +## Browser Support + +- **Chrome/Edge**: Full enterprise deployment support via Group Policy (Windows) and MDM (macOS/Linux) +- **Firefox**: Enterprise deployment via `policies.json` file (all platforms) + ## Contents -- `admx/` - Group Policy Administrative Templates +- `admx/` - Group Policy Administrative Templates (Chrome/Edge) - `Check-Extension.admx` - Policy definitions file - `en-US` - English language resources - `Check-Extension.adml` - XML configuration file for Check -- `unix/` - Unix-based deployment (macOS & Linux) +- `macos-linux/` - Unix-based deployment (macOS & Linux) - Configuration Profiles for macOS - Browser policy files for Linux - Universal deployment scripts -- `Check-Extension-Policy.reg` - Windows registry file for direct policy application -- `Deploy-ADMX.ps1` - PowerShell script for Windows ADMX deployment -- `Deply-Windows-Chrome-and-Edge.ps1` PowerShell script for manual Windows deployment also used for RMM deployment +- `firefox/` - Firefox-specific deployments (all platforms) + - `policies.json` - Template for Firefox enterprise policy management +- `Check-Extension-Policy.reg` - Windows registry file for direct policy application (Chrome/Edge) +- `Deploy-ADMX.ps1` - PowerShell script for Windows ADMX deployment (Chrome/Edge) +- `Deploy-Windows-Chrome-and-Edge.ps1` - PowerShell script for manual Windows deployment, also used for RMM deployment + +## Quick Links + +- **Chrome/Edge Deployment**: See `Deploy-Windows-Chrome-and-Edge.ps1` for Windows, `macos-linux/` for macOS/Linux +- **Firefox Deployment**: See `firefox/policies.json` template and [Firefox Deployment Guide](../docs/deployment/firefox-deployment.md) +- **Configuration Schema**: See `../config/managed_schema.json` for all available settings ## Security Considerations diff --git a/enterprise/admx/Check-Extension.admx b/enterprise/admx/Check-Extension.admx index 2635bcfb..d69c2620 100644 --- a/enterprise/admx/Check-Extension.admx +++ b/enterprise/admx/Check-Extension.admx @@ -71,6 +71,15 @@ + + + + + + + + + @@ -246,6 +255,15 @@ + + + + + + + + + diff --git a/enterprise/admx/en-US/Check-Extension.adml b/enterprise/admx/en-US/Check-Extension.adml index 65114ba4..078a8c12 100644 --- a/enterprise/admx/en-US/Check-Extension.adml +++ b/enterprise/admx/en-US/Check-Extension.adml @@ -69,6 +69,16 @@ When enabled (default): A green badge or indicator shows when users are on a verified Microsoft login page. When disabled: No visual indicator is shown for valid pages. + + Valid page badge timeout + + This policy controls the auto-dismiss timeout for the valid page badge in seconds. + + Set to 0 for no timeout (badge stays visible until manually dismissed). + Set to a value between 1 and 300 to automatically dismiss the badge after that many seconds. + + Default: 5 seconds + Enable page blocking @@ -244,6 +254,16 @@ When enabled (default): A green badge or indicator shows when users are on a verified Microsoft login page. When disabled: No visual indicator is shown for valid pages. + + Valid page badge timeout (Chrome) + + This policy controls the auto-dismiss timeout for the valid page badge in seconds in Google Chrome. + + Set to 0 for no timeout (badge stays visible until manually dismissed). + Set to a value between 1 and 300 to automatically dismiss the badge after that many seconds. + + Default: 5 seconds + Enable page blocking (Chrome) @@ -376,6 +396,14 @@ Update Interval (hours): + + Valid Page Badge Timeout (seconds, 0 = no timeout): + + + Valid Page Badge Timeout (seconds, 0 = no timeout): + diff --git a/enterprise/firefox/README.md b/enterprise/firefox/README.md new file mode 100644 index 00000000..b408cdea --- /dev/null +++ b/enterprise/firefox/README.md @@ -0,0 +1,87 @@ +# Firefox Enterprise Deployment + +This directory contains the Firefox enterprise deployment template for the Check extension. + +## File + +- **`policies.json`** - Template for Firefox enterprise policy management + +## Overview + +Firefox uses a `policies.json` file for enterprise extension management. This file controls: +- Extension installation and updates +- Extension configuration (settings, branding, etc.) +- Extension locking (prevent users from disabling) + +## Quick Start + +1. Copy `policies.json` to the appropriate location for your OS: + - **Windows**: `%ProgramFiles%\Mozilla Firefox\distribution\policies.json` + - **macOS**: `/Applications/Firefox.app/Contents/Resources/distribution/policies.json` + - **Linux**: `/etc/firefox/policies/policies.json` + +2. Update the `install_url` with your signed .xpi file location + +3. Customize the extension settings in the `3rdparty.Extensions` section + +4. Restart Firefox on all target systems + +## Installation URL + +Before deploying, you need to: +1. Build the Firefox version: `npm run build:firefox` +2. Package and sign the extension through Mozilla Add-ons +3. Host the signed .xpi file on your server +4. Update `install_url` in policies.json with your .xpi URL + +## Configuration + +The template includes all Check configuration options: + +### Force Installation +```json +"Extensions": { + "Install": ["https://your-server.com/check-extension.xpi"], + "Locked": ["check@cyberdrain.com"] +} +``` + +### Extension Settings +All Check settings are configured in the `3rdparty.Extensions.check@cyberdrain.com` section: +- Security notifications +- Page blocking +- CIPP reporting +- Detection rules +- Custom branding +- Webhook integration + +See `../../config/managed_schema.json` for the complete settings schema. + +## Extension ID + +Firefox extension ID: **`check@cyberdrain.com`** + +This ID is defined in `manifest.firefox.json` and must match in all policy configurations. + +## Complete Documentation + +For detailed deployment instructions, see: +- [Firefox Support Guide](../../docs/firefox-support.md) +- [Firefox Deployment Guide](../../docs/deployment/firefox-deployment.md) + +## Verification + +After deployment, verify the policy is active: +1. Open Firefox +2. Navigate to `about:policies` +3. Check that your policies appear under "Active Policies" +4. Verify the extension is installed at `about:addons` + +## Support + +For Firefox deployment issues: +- Check file permissions (policies.json must be readable) +- Verify JSON syntax +- Ensure Firefox version is 109+ +- Check the Firefox Browser Console for errors +- See troubleshooting section in the [Firefox Deployment Guide](../../docs/deployment/firefox-deployment.md) diff --git a/enterprise/firefox/policies.json b/enterprise/firefox/policies.json new file mode 100644 index 00000000..be564a92 --- /dev/null +++ b/enterprise/firefox/policies.json @@ -0,0 +1,53 @@ +{ + "policies": { + "Extensions": { + "Install": [ + "" + ], + "Locked": [ + "check@cyberdrain.com" + ] + }, + "ExtensionSettings": { + "check@cyberdrain.com": { + "installation_mode": "force_installed", + "install_url": "", + "default_area": "navbar" + } + }, + "3rdparty": { + "Extensions": { + "check@cyberdrain.com": { + "showNotifications": true, + "enableValidPageBadge": true, + "validPageBadgeTimeout": 5, + "enablePageBlocking": true, + "enableCippReporting": false, + "cippServerUrl": "", + "cippTenantId": "", + "customRulesUrl": "https://raw.githubusercontent.com/CyberDrain/Check/refs/heads/main/rules/detection-rules.json", + "updateInterval": 24, + "urlAllowlist": [], + "enableDebugLogging": false, + "customBranding": { + "companyName": "", + "companyURL": "https://cyberdrain.com/", + "productName": "", + "supportEmail": "", + "primaryColor": "#F77F00", + "logoUrl": "" + }, + "genericWebhook": { + "enabled": false, + "url": "https://webhook.example.com/endpoint", + "events": [ + "detection_alert", + "page_blocked", + "threat_detected" + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/enterprise/macos-linux/README.md b/enterprise/macos-linux/README.md index bcd5877c..8e1d55a2 100644 --- a/enterprise/macos-linux/README.md +++ b/enterprise/macos-linux/README.md @@ -149,6 +149,7 @@ All settings are based on the managed schema and include: ### Security Settings - **`showNotifications`** - Display security notifications (default: true) - **`enableValidPageBadge`** - Show validation badge on legitimate pages (default: true) +- **`validPageBadgeTimeout`** - Auto-dismiss timeout for valid page badge in seconds (default: 5, set to 0 for no timeout) - **`enablePageBlocking`** - Enable blocking of malicious pages (default: true) - **`enableCippReporting`** - Enable CIPP server reporting (default: false) - **`enableDebugLogging`** - Enable debug logging (default: false) diff --git a/enterprise/macos-linux/chrome-managed-policy.json b/enterprise/macos-linux/chrome-managed-policy.json index 61f43a98..58ac0eb7 100644 --- a/enterprise/macos-linux/chrome-managed-policy.json +++ b/enterprise/macos-linux/chrome-managed-policy.json @@ -10,6 +10,7 @@ "benimdeioplgkhanklclahllklceahbe": { "showNotifications": true, "enableValidPageBadge": true, + "validPageBadgeTimeout": 5, "enablePageBlocking": true, "enableCippReporting": false, "cippServerUrl": "", @@ -23,6 +24,15 @@ "supportEmail": "", "primaryColor": "#F77F00", "logoUrl": "" + }, + "genericWebhook": { + "enabled": false, + "url": "https://webhook.example.com/endpoint", + "events": [ + "detection_alert", + "page_blocked", + "threat_detected" + ] } } } diff --git a/enterprise/macos-linux/edge-managed-policy.json b/enterprise/macos-linux/edge-managed-policy.json index 6e8b3d1d..fc44bd0d 100644 --- a/enterprise/macos-linux/edge-managed-policy.json +++ b/enterprise/macos-linux/edge-managed-policy.json @@ -10,6 +10,7 @@ "knepjpocdagponkonnbggpcnhnaikajg": { "showNotifications": true, "enableValidPageBadge": true, + "validPageBadgeTimeout": 5, "enablePageBlocking": true, "enableCippReporting": false, "cippServerUrl": "", @@ -23,6 +24,15 @@ "supportEmail": "", "primaryColor": "#F77F00", "logoUrl": "" + }, + "genericWebhook": { + "enabled": false, + "url": "https://webhook.example.com/endpoint", + "events": [ + "detection_alert", + "page_blocked", + "threat_detected" + ] } } } diff --git a/manifest.firefox.json b/manifest.firefox.json new file mode 100644 index 00000000..05962811 --- /dev/null +++ b/manifest.firefox.json @@ -0,0 +1,73 @@ +{ + "manifest_version": 3, + "name": "Check by CyberDrain", + "version": "1.0.5", + "description": "Protect against phishing attacks targeting Microsoft 365 login pages with enterprise-grade detection", + "permissions": [ + "storage", + "activeTab", + "tabs", + "scripting", + "webRequest", + "alarms", + "identity" + ], + "host_permissions": [""], + "background": { + "scripts": ["scripts/background.js"], + "type": "module" + }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["scripts/browser-polyfill-inline.js", "scripts/content.js"], + "css": ["styles/content.css"], + "run_at": "document_idle", + "all_frames": true + } + ], + "action": { + "default_popup": "popup/popup.html", + "default_title": "Check", + "default_icon": { + "16": "images/icon16.png", + "32": "images/icon32.png", + "48": "images/icon48.png", + "128": "images/icon128.png" + } + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": true + }, + "icons": { + "16": "images/icon16.png", + "32": "images/icon32.png", + "48": "images/icon48.png", + "128": "images/icon128.png" + }, + "web_accessible_resources": [ + { + "resources": [ + "config/*", + "rules/*", + "images/*", + "blocked.html", + "scripts/utils/logger.js", + "scripts/blocked.js", + "scripts/browser-polyfill.js", + "scripts/browser-polyfill-inline.js" + ], + "matches": [""] + } + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; font-src 'self' https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" + }, + "browser_specific_settings": { + "gecko": { + "id": "check@cyberdrain.com", + "strict_min_version": "109.0" + } + } +} diff --git a/manifest.json b/manifest.json index d733f8c5..03f802cc 100644 --- a/manifest.json +++ b/manifest.json @@ -21,7 +21,7 @@ "content_scripts": [ { "matches": ["http://*/*", "https://*/*", "file:///*/*"], - "js": ["scripts/content.js"], + "js": ["scripts/browser-polyfill-inline.js", "scripts/content.js"], "css": ["styles/content.css"], "run_at": "document_idle", "all_frames": true @@ -52,7 +52,9 @@ "images/*", "blocked.html", "scripts/utils/logger.js", - "scripts/blocked.js" + "scripts/blocked.js", + "scripts/browser-polyfill.js", + "scripts/browser-polyfill-inline.js" ], "matches": [""] } diff --git a/options/options.html b/options/options.html index c187a190..7cf1a0d6 100644 --- a/options/options.html +++ b/options/options.html @@ -91,6 +91,15 @@

Extension Settings

Block access to detected phishing pages

+
+ +

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..7d841e62 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"; @@ -1331,13 +1339,33 @@ class CheckPopup { try { this.showNotification("Re-triggering analysis...", "info"); + // First, ensure background script is awake (wake it up with a ping) + const backgroundReady = await this.checkBackgroundScript(); + if (!backgroundReady) { + console.warn("Check: Background script not responding, trying to wake it up"); + // Try to wake it up by waiting a bit + const wakeupSuccess = await this.waitForBackgroundScript(); + if (!wakeupSuccess) { + console.error("Check: Failed to wake up background script"); + this.showNotification( + "Extension background script is not responding. Try reloading the extension.", + "error" + ); + return; + } + } + // Send message to content script to re-run protection chrome.tabs.sendMessage( this.currentTab.id, { type: "RETRIGGER_ANALYSIS" }, (response) => { if (chrome.runtime.lastError) { - this.showNotification("Failed to communicate with page", "error"); + console.error("Check: Failed to communicate with page:", chrome.runtime.lastError); + this.showNotification( + "Failed to communicate with page. Content script may not be loaded on this page.", + "error" + ); return; } @@ -1375,6 +1403,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 +1900,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 +1986,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..46f448a7 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)$", @@ -36,7 +36,7 @@ "^https:\\/\\/([^.]+\\.)*live\\.com(/.*)?$" ], "exclusion_system": { - "description": "Centralized exclusion system to prevent false positives on legitimate sites", + "description": "Centralized exclusion system to prevent false positives on legitimate sites (Microsoft partners, SSO providers, major platforms)", "domain_patterns": [ "^https:\\/\\/[^/]*\\.cipp\\.app(/.*)?$", "^https:\\/\\/(.*\\.)?cyberdrain\\.com(/.*)?$", @@ -58,7 +58,22 @@ ], "context_indicators": { "description": "Additional context that indicates legitimate discussion vs phishing", - "legitimate_contexts": [], + "legitimate_contexts": [ + "migration tool", + "governance tool", + "management platform", + "consulting services", + "microsoft partner", + "microsoft 365 solutions", + "microsoft 365 migration", + "microsoft 365 governance", + "sharepoint migration", + "tenant migration", + "tenant-to-tenant", + "cloud migration", + "microsoft 365 management", + "copilot readiness" + ], "legitimate_sso_patterns": [], "suspicious_contexts": [ "verify now", @@ -125,6 +140,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": 0.5, + "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": 0.5, + "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 +214,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 +249,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 +257,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" } @@ -434,6 +510,22 @@ "pattern_source": "microsoft_domain_patterns" }, "description": "Validate referrer against Microsoft domain patterns for legitimate authentication flows" + }, + { + "id": "detect_form_action_modification", + "type": "code_driven", + "code_driven": true, + "code_logic": { + "type": "pattern_count", + "patterns": [ + "addEventListener\\s*\\(\\s*['\"]submit['\"]", + "\\.action\\s*=\\s*['\"]https?://(?!login\\.microsoftonline)[^'\"]+['\"]" + ], + "flags": "i", + "min_count": 2 + }, + "weight": -30, + "description": "JavaScript code modifying form action on submit" } ], "thresholds": { @@ -452,10 +544,78 @@ "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_033_suspicious_event_listeners", + "code_driven": true, + "code_logic": { + "type": "pattern_count", + "patterns": [ + "addEventListener\\s*\\(\\s*['\"]submit['\"]", + "\\.action\\s*=\\s*['\"](?!https://login\\.microsoftonline)", + "form\\.setAttribute\\s*\\(['\"]action['\"]" + ], + "flags": "i", + "min_count": 2 + }, + "severity": "high", + "description": "Form with submit listeners that modify action attribute", + "action": "block", + "category": "dom_manipulation", + "confidence": 0.9, + "weight": 20 + }, { "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 +644,15 @@ }, { "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 +661,111 @@ }, { "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": "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" + ] + } + ] + }, + { + "type": "not_if_contains", + "prohibited": [ + "migration tool", + "governance tool", + "management platform", + "consulting service", + "microsoft partner", + "solutions provider", + "microsoft 365 migration", + "microsoft 365 governance", + "sharepoint migration", + "tenant migration", + "tenant-to-tenant migration", + "microsoft 365 management", + "microsoft 365 solutions", + "build a secure", + "ai-ready microsoft", + "copilot readiness", + "microsoft teams management", + "azure active directory management", + "office 365 migration" + ] + } + ] + }, "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 +782,251 @@ }, { "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": "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" + ] + } + ] + }, + { + "type": "not_if_contains", + "prohibited": [ + "migration tool", + "governance tool", + "management platform", + "consulting service", + "microsoft partner", + "solutions provider", + "microsoft 365 migration", + "microsoft 365 governance", + "sharepoint migration", + "tenant migration", + "tenant-to-tenant migration", + "microsoft 365 management", + "microsoft 365 solutions", + "build a secure", + "ai-ready microsoft", + "copilot readiness", + "microsoft teams management", + "azure active directory management", + "office 365 migration" + ] + } + ] + }, "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": "all_of", + "operations": [ + { + "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 + }, + { + "type": "not_if_contains", + "prohibited": [ + "migration tool", + "governance tool", + "management platform", + "consulting service", + "microsoft partner", + "solutions provider", + "microsoft 365 migration", + "microsoft 365 governance", + "sharepoint migration", + "tenant migration", + "tenant-to-tenant migration", + "microsoft 365 management", + "microsoft 365 solutions", + "build a secure", + "ai-ready microsoft", + "copilot readiness", + "microsoft teams management", + "azure active directory management", + "office 365 migration" + ] + } + ] + }, "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": "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" + ] + } + ] + }, + { + "type": "not_if_contains", + "prohibited": [ + "migration tool", + "governance tool", + "management platform", + "consulting service", + "microsoft partner", + "solutions provider", + "microsoft 365 migration", + "microsoft 365 governance", + "sharepoint migration", + "tenant migration", + "tenant-to-tenant migration", + "microsoft 365 management", + "microsoft 365 solutions", + "build a secure", + "ai-ready microsoft", + "copilot readiness", + "microsoft teams management", + "azure active directory management", + "office 365 migration" + ] + } + ] + }, "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 +1040,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,18 +1064,174 @@ }, { "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", "severity": "critical", "description": "Enhanced detection of domains mimicking Microsoft services with security/login keywords (excludes legitimate SSO)", "action": "block", "category": "domain_spoofing", - "confidence": 0.95 + "confidence": 0.95, + "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", + ".auth/login/", + "azure static web apps", + "easy auth", + "easyauth" + ] + } }, { "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": "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" + ] + } + ] + }, + { + "type": "not_if_contains", + "prohibited": [ + "migration tool", + "governance tool", + "management platform", + "consulting service", + "microsoft partner", + "solutions provider", + "microsoft 365 migration", + "microsoft 365 governance", + "sharepoint migration", + "tenant migration", + "tenant-to-tenant migration", + "microsoft 365 management", + "microsoft 365 solutions", + "build a secure", + "ai-ready microsoft", + "copilot readiness", + "microsoft teams management", + "azure active directory management", + "office 365 migration" + ] + } + ] + }, "severity": "high", "description": "Impersonation of Microsoft security team (excludes legitimate SSO and third-party auth)", "action": "block", @@ -633,8 +1240,202 @@ }, { "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": "all_of", + "operations": [ + { + "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 + } + ] + }, + { + "type": "not_if_contains", + "prohibited": [ + "migration tool", + "governance tool", + "management platform", + "consulting service", + "microsoft partner", + "solutions provider", + "microsoft 365 migration", + "microsoft 365 governance", + "sharepoint migration", + "tenant migration", + "tenant-to-tenant migration", + "microsoft 365 management", + "microsoft 365 solutions", + "build a secure", + "ai-ready microsoft", + "copilot readiness", + "microsoft teams management", + "azure active directory management", + "office 365 migration" + ] + } + ] + }, "severity": "high", "description": "Common Microsoft 365 phishing keywords and variations", "action": "block", @@ -643,10 +1444,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 +1492,164 @@ }, { "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": "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" + ] + } + ] + }, + { + "type": "not_if_contains", + "prohibited": [ + "migration tool", + "governance tool", + "management platform", + "consulting service", + "microsoft partner", + "solutions provider", + "microsoft 365 migration", + "microsoft 365 governance", + "sharepoint migration", + "tenant migration", + "tenant-to-tenant migration", + "microsoft 365 management", + "microsoft 365 solutions", + "build a secure", + "ai-ready microsoft", + "copilot readiness", + "microsoft teams management", + "azure active directory management", + "office 365 migration" + ] + } + ] + }, "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": "low", + "description": "CSS prevents text selection - anti-analysis technique (supporting evidence - should not block alone)", + "action": "warn", + "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": "low", + "description": "Honeypot fields used to detect and filter automated bot submissions (supporting evidence - should not block alone)", + "action": "warn", + "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": "medium", + "description": "Obfuscated links with empty tags - phishing technique (supporting evidence - should not block alone)", + "action": "warn", + "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": "medium", + "description": "Multiple empty tags used to obfuscate text (supporting evidence - should not block alone)", + "action": "warn", + "category": "text_obfuscation", + "confidence": 0.9 } ], "legitimate_patterns": [ @@ -690,7 +1657,7 @@ "id": "leg_001", "pattern": "login\\.microsoftonline\\.com", "description": "Official Microsoft Online login domain", - "confidence": 1.0 + "confidence": 1 }, { "id": "leg_002", @@ -738,7 +1705,7 @@ "https:\\/\\/*.bing.com/" ], "description": "Required domains in content-security-policy-report-only header", - "confidence": 1.0 + "confidence": 1 }, { "id": "leg_006", @@ -960,4 +1927,4 @@ "auto_update": true, "fallback_on_error": true } -} \ No newline at end of file +} diff --git a/scripts/background.js b/scripts/background.js index 6155e136..8d50fe83 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); }); @@ -774,9 +810,7 @@ class CheckBackground { async _doFlush() { const cur = - (await safe( - chrome.storage.local.get(["accessLogs", "securityEvents"]) - )) || {}; + (await safe(storage.local.get(["accessLogs", "securityEvents"]))) || {}; const access = (cur.accessLogs || []) .concat(this.pendingLocal.accessLogs) .slice(-1000); @@ -787,7 +821,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 +867,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 +884,7 @@ class CheckBackground { } β†’ ${urlBasedVerdict}` ); await safe( - chrome.storage.session.set({ + storage.session.set({ ["verdict:" + tabId]: { verdict: urlBasedVerdict, url: tab.url }, }) ); @@ -931,7 +965,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 +989,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 +1017,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 +1049,7 @@ class CheckBackground { ); await safe( - chrome.storage.session.set({ + storage.session.set({ ["verdict:" + tabId]: { verdict: "rogue-app", url: sender.tab.url, @@ -1045,7 +1079,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 +1179,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 +1364,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 = @@ -1540,7 +1566,10 @@ class CheckBackground { case "send_webhook": try { if (!message.webhookType || !message.data) { - sendResponse({ success: false, error: "Invalid webhook message" }); + sendResponse({ + success: false, + error: "Invalid webhook message", + }); return; } @@ -1550,7 +1579,7 @@ class CheckBackground { const metadata = { config: config, userProfile: userProfile, - extensionVersion: chrome.runtime.getManifest().version + extensionVersion: chrome.runtime.getManifest().version, }; const result = await this.webhookManager.sendWebhook( @@ -1713,24 +1742,8 @@ class CheckBackground { this.pendingLocal.securityEvents.push(logEntry); this.scheduleFlush(); - // Send to CIPP if enabled using the correct method - if (config?.enableCippReporting && config?.cippServerUrl) { - try { - await this.handleCippReport({ - type: logEntry.event.type, - severity: logEntry.event.threatLevel || "medium", - timestamp: logEntry.timestamp, - url: logEntry.event.url, - reason: logEntry.event.reason || "Security event logged", - tabId: logEntry.tabId, - event: logEntry.event, - profile: logEntry.profile, - }); - } catch (error) { - logger.error("Failed to send event to CIPP:", error); - // Don't fail the entire logging operation if CIPP is unavailable - } - } + // NOTE: Webhooks are sent directly via send_webhook and send_cipp_report messages + // Do NOT send webhooks here to avoid duplicates } enhanceEventForLogging(event) { @@ -1858,8 +1871,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 +1933,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 +1951,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 +1970,17 @@ 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() { @@ -2181,8 +2182,8 @@ class CheckBackground { try { const config = await this.configManager.getConfig(); - if (!config?.enableCippReporting || !config?.cippServerUrl) { - logger.debug("CIPP reporting disabled or no server URL configured"); + if (!config?.enableCippReporting && !config?.genericWebhook?.enabled) { + logger.debug("Webhooks disabled"); return; } @@ -2192,7 +2193,7 @@ class CheckBackground { config: config, userProfile: userProfile, extensionVersion: chrome.runtime.getManifest().version, - isPrivateIP: this.webhookManager.isPrivateIP(basePayload.redirectTo) + isPrivateIP: this.webhookManager.isPrivateIP(basePayload.redirectTo), }; const result = await this.webhookManager.sendWebhook( @@ -2288,7 +2289,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..60a25222 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,299 @@ 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 (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); } - } catch (e) {} - cachedStylesheetAnalysis = analysis; - return analysis; + }); + + toRemove.forEach((element) => injectedElements.delete(element)); + + if (toRemove.length > 0) { + logger.debug( + `Cleaned up ${toRemove.length} disconnected elements from tracking` + ); + } } /** @@ -238,6 +504,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 +592,10 @@ 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 +611,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 +1038,6 @@ if (window.checkExtensionLoaded) { }, consoleLogs: capturedLogs.slice(), // Copy the captured logs pageSource: lastScannedPageSource || document.documentElement.outerHTML, - timestamp: Date.now(), }; console.log( @@ -738,19 +1045,54 @@ 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 +1176,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,36 +1264,64 @@ 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 + return { + isLogonPage: false, + hasElements: false, + primaryFound: 0, + totalWeight: 0, + totalElements: 0, + foundElements: [], + pageSource: null, + }; } if (!detectionRules?.m365_detection_requirements) { - return false; + logger.error("No M365 detection requirements in rules"); + 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; + lastPageSourceScanTime = Date.now(); - // Lower threshold - just need ANY Microsoft-related elements + let primaryFound = 0; let totalWeight = 0; let totalElements = 0; + const foundElementsList = []; + const missingElementsList = []; const allElements = [ ...(requirements.primary_elements || []), ...(requirements.secondary_elements || []), ]; + // Single loop - check all elements once for (const element of allElements) { try { let found = false; @@ -958,11 +1329,86 @@ 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") { found = element.patterns.some((pattern) => { const regex = new RegExp(pattern, "i"); return regex.test(pageSource); }); + + // Also check external stylesheets if not found in page source + if (!found) { + try { + const styleSheets = Array.from(document.styleSheets); + found = styleSheets.some((sheet) => { + try { + if (sheet.cssRules) { + const cssText = Array.from(sheet.cssRules) + .map((rule) => rule.cssText) + .join(" "); + return element.patterns.some((pattern) => { + const regex = new RegExp(pattern, "i"); + return regex.test(cssText); + }); + } + } catch (corsError) { + // CORS blocked - check stylesheet URL for Microsoft patterns + if (sheet.href && element.id === "ms_external_css") { + const regex = new RegExp(element.patterns[0], "i"); + return regex.test(sheet.href); + } + } + return false; + }); + } catch (stylesheetError) { + logger.debug( + `Could not check stylesheets for ${element.id}: ${stylesheetError.message}` + ); + } + } } else if (element.type === "url_pattern") { found = element.patterns.some((pattern) => { const regex = new RegExp(pattern, "i"); @@ -976,171 +1422,24 @@ if (window.checkExtensionLoaded) { } 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 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; - } - - const requirements = detectionRules.m365_detection_requirements; - const pageSource = getPageSource(); - - // Store the page source for debugging purposes - lastScannedPageSource = pageSource; - lastPageSourceScanTime = Date.now(); - - let primaryFound = 0; - let totalWeight = 0; - let totalElements = 0; - const foundElementsList = []; - const missingElementsList = []; - - // Check primary elements (Microsoft-specific) - 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") { - // Check for CSS patterns in the page source - found = element.patterns.some((pattern) => { - const regex = new RegExp(pattern, "i"); - return regex.test(pageSource); - }); - - // Also check external stylesheets if not found in page source - if (!found) { - try { - const styleSheets = Array.from(document.styleSheets); - found = styleSheets.some((sheet) => { - try { - if (sheet.cssRules) { - const cssText = Array.from(sheet.cssRules) - .map((rule) => rule.cssText) - .join(" "); - return element.patterns.some((pattern) => { - const regex = new RegExp(pattern, "i"); - return regex.test(cssText); - }); - } - } catch (corsError) { - // CORS blocked - check stylesheet URL for Microsoft patterns - if (sheet.href && element.id === "ms_external_css") { - const regex = new RegExp(element.patterns[0], "i"); - return regex.test(sheet.href); - } - } - return false; - }); - } catch (stylesheetError) { - logger.debug( - `Could not check stylesheets for ${element.id}: ${stylesheetError.message}` - ); - } - } - } - - if (found) { - totalElements++; - totalWeight += element.weight || 1; - if (element.category === "primary") { - primaryFound++; - } - foundElementsList.push(element.id); - logger.debug( - `βœ“ Found ${element.category || "unknown"} element: ${ - element.id - } (weight: ${element.weight || 1})` - ); - } else { - missingElementsList.push(element.id); - logger.debug( - `βœ— Missing ${element.category || "unknown"} element: ${ - element.id - }` - ); + totalWeight += element.weight || 1; + if (element.category === "primary") { + primaryFound++; + } + foundElementsList.push(element.id); + logger.debug( + `βœ“ Found ${element.category || "unknown"} element: ${ + element.id + } (weight: ${element.weight || 1})` + ); + } else { + missingElementsList.push(element.id); + logger.debug( + `βœ— Missing ${element.category || "unknown"} element: ${ + element.id + }` + ); } } catch (elementError) { logger.warn( @@ -1151,94 +1450,98 @@ 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; + thresholds.minimum_secondary_only_weight || 9; const minSecondaryOnlyElements = - thresholds.minimum_secondary_only_elements || 5; + 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) { + // Calculate hasElements (looser threshold for element presence) + // Use configured thresholds instead of hardcoded values + const hasElements = + primaryFound > 0 || + totalWeight >= minWeight || + (totalElements >= minTotal && totalWeight >= minWeight); + + // 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}` + ); + } + logger.log(`Found elements: [${foundElementsList.join(", ")}]`); + if (missingElementsList.length > 0) { + logger.log(`Missing elements: [${missingElementsList.join(", ")}]`); + } logger.log( - `M365 logon detection (with primary): Primary=${primaryFound}/${minPrimary}, Weight=${totalWeight}/${minWeight}, Total=${totalElements}/${minTotal}` + `🎯 Detection Result: βœ… DETECTED as Microsoft 365 logon page` ); - } else { logger.log( - `M365 logon detection (secondary only): Weight=${totalWeight}/${minSecondaryOnlyWeight}, Total=${totalElements}/${minSecondaryOnlyElements}` + "πŸ“‹ Next step: Analyzing if this is legitimate or phishing attempt..." ); - } - 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`); - - // 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" - }` + } 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` ); - 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) { + } else { logger.log( - "πŸ“‹ Next step: Analyzing if this is legitimate or phishing attempt..." + `πŸ“„ 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 +1846,334 @@ 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, context) => { + const lower = source.toLowerCase(); + + // Special handling: if check_url_only is true, only check the URL from context + if (params.check_url_only && context.currentUrl) { + const urlLower = context.currentUrl.toLowerCase(); + + // Check if any required substring is present in URL + const hasRequired = params.required.some((req) => + urlLower.includes(req.toLowerCase()) + ); + + if (!hasRequired) return false; + + // Check if any prohibited substring is present in URL + const hasProhibited = params.prohibited.some((pro) => + urlLower.includes(pro.toLowerCase()) + ); + + return !hasProhibited; + } + + // Default behavior: check source content + // 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; + }, + + /** + * Exclusion check - returns FALSE if any prohibited substring is present + * Used to exclude legitimate contexts from detection + */ + not_if_contains: (source, params) => { + const lower = source.toLowerCase(); + + // If any prohibited substring is present, return false (exclude/skip this rule) + const hasProhibited = params.prohibited.some((pro) => + lower.includes(pro.toLowerCase()) + ); + + return !hasProhibited; // True = continue with rule, False = skip rule + }, + }; + + /** + * 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 +2336,33 @@ 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,101 +2370,140 @@ 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` ); } - // Log first few indicators for debugging - const firstThree = detectionRules.phishing_indicators.slice(0, 3); - logger.log("πŸ“‹ First 3 indicators:"); - firstThree.forEach((ind, i) => { - logger.log(` ${i + 1}. ${ind.id}: ${ind.pattern} (${ind.severity})`); + // Log ALL indicators for debugging + logger.log(`πŸ“‹ All ${detectionRules.phishing_indicators.length} indicators loaded:`); + detectionRules.phishing_indicators.forEach((ind, i) => { + const patternPreview = ind.pattern + ? ind.pattern.substring(0, 50) + (ind.pattern.length > 50 ? '...' : '') + : ind.code_driven + ? `[code-driven: ${ind.code_logic?.type || 'unknown'}]` + : '[no pattern]'; + logger.log(` ${i + 1}. ${ind.id}: ${patternPreview} (${ind.severity})`); }); - // Performance protection: Add timeout mechanism - const startTime = Date.now(); - const PROCESSING_TIMEOUT = 5000; // Standard timeout - let processedCount = 0; - - // 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 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; - if ( - backgroundResult && - (backgroundResult.threats.length > 0 || backgroundResult.score >= 0) - ) { - logger.log(`⏱️ PERF: Background processing completed successfully`); - - // Apply context filtering and SSO checks to background results - const filteredThreats = []; - for (const threat of backgroundResult.threats) { - let includeThread = true; + if ( + backgroundResult && + (backgroundResult.threats.length > 0 || backgroundResult.score >= 0) + ) { + const processingTime = Date.now() - startTime; + lastProcessingTime = processingTime; // CRITICAL: Track time - // Apply context_required filtering from rules - const indicator = detectionRules.phishing_indicators.find( - (ind) => ind.id === threat.id + logger.log( + `⏱️ PERF: Background processing completed successfully in ${processingTime}ms` ); - 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; + + // Apply context filtering and SSO checks to background results + const filteredThreats = []; + for (const threat of backgroundResult.threats) { + let includeThread = true; + + 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 (!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` + ); + } } - } - // 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` + ); + + // 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 }; + 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 +2514,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 @@ -1846,42 +2527,183 @@ if (window.checkExtensionLoaded) { 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(), currentUrl: window.location.href } + ); + 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()) + ); - // 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"; - } + 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(); - // 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; + // 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"; + } + + // 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 +2785,229 @@ 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 +3016,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 }); } }; @@ -1992,14 +3034,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 +3051,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 +3154,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 */ @@ -2368,6 +3380,64 @@ if (window.checkExtensionLoaded) { } break; + case "code_driven": + // Support code-driven rules using same logic as phishing indicators + if (rule.code_driven === true && rule.code_logic) { + try { + // Use DetectionPrimitives if available + if (DetectionPrimitives[rule.code_logic.type]) { + try { + ruleTriggered = evaluatePrimitive( + pageHTML, + rule.code_logic, + { cache: new Map(), currentUrl: location.href } + ); + } catch (primitiveError) { + logger.warn( + `Primitive evaluation failed for rule ${rule.id}:`, + primitiveError.message + ); + } + } + // Legacy code-driven types + else if (rule.code_logic.type === "substring") { + ruleTriggered = (rule.code_logic.substrings || []).every( + (sub) => pageHTML.includes(sub) + ); + } else if (rule.code_logic.type === "substring_not") { + ruleTriggered = + (rule.code_logic.substrings || []).every((sub) => + pageHTML.includes(sub) + ) && + (rule.code_logic.not_substrings || []).every( + (sub) => !pageHTML.includes(sub) + ); + } else if (rule.code_logic.type === "pattern_count") { + let matchCount = 0; + for (const pattern of rule.code_logic.patterns || []) { + try { + const regex = new RegExp( + pattern, + rule.code_logic.flags || "i" + ); + if (regex.test(pageHTML)) { + matchCount++; + } + } catch (e) { + // Skip invalid patterns + } + } + ruleTriggered = matchCount >= (rule.code_logic.min_count || 1); + } + } catch (codeDrivenError) { + logger.warn( + `Code-driven rule ${rule.id} failed:`, + codeDrivenError.message + ); + } + } + break; + default: logger.warn(`Unknown rule type: ${rule.type}`); } @@ -2414,18 +3484,53 @@ if (window.checkExtensionLoaded) { /** * Main protection logic following CORRECTED specification */ - async function runProtection(isRerun = false) { + async function runProtection(isRerun = false, forceRescan = false, options = {}) { + // Early exit if page has been escalated to block (unless forced) + if (escalatedToBlock && !forceRescan) { + 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 (unless forced) + if (isRerun && showingBanner && !forceRescan) { + logger.log( + `πŸ›‘ runProtection() called but banner already displayed - ignoring re-scan` + ); + return; + } + + // Log forced re-scan + if (forceRescan) { + logger.log('πŸ”„ FORCED RE-SCAN: User manually triggered re-scan from popup'); + } + 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 (options.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"); @@ -2434,10 +3539,17 @@ if (window.checkExtensionLoaded) { // Load configuration from background (includes merged enterprise policies) const config = await new Promise((resolve) => { chrome.runtime.sendMessage({ type: "GET_CONFIG" }, (response) => { - if (chrome.runtime.lastError || !response || !response.success || !response.config) { + if ( + chrome.runtime.lastError || + !response || + !response.success || + !response.config + ) { // Optionally log the error for debugging if (chrome.runtime.lastError) { - logger.log(`[M365-Protection] Error getting config from background: ${chrome.runtime.lastError.message}`); + logger.log( + `[M365-Protection] Error getting config from background: ${chrome.runtime.lastError.message}` + ); } // Fallback to local storage if background not available or response invalid chrome.storage.local.get(["config"], (result) => { @@ -2494,18 +3606,44 @@ if (window.checkExtensionLoaded) { return; } - // Rate limiting for DOM change re-runs - if (isRerun) { + // Rate limiting for DOM change re-runs (bypass if forced) + if (isRerun && !forceRescan) { 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 if (forceRescan) { + // For forced re-scans, reset timing and increment scan count + lastScanTime = Date.now(); + scanCount++; + logger.log(`πŸ”„ Forced re-scan initiated (scan count: ${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 +3692,18 @@ 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)}` - ); - logger.debug( - `Is Microsoft domain: ${isMicrosoftDomain(window.location.href)}` - ); + logger.debug(`Is trusted login domain: ${domainTrust.isTrustedLogin}`); + logger.debug(`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" ); @@ -2662,7 +3799,7 @@ if (window.checkExtensionLoaded) { // Send critical CIPP alert sendCippReport({ type: "critical_rogue_app_detected", - url: location.href, + url: defangUrl(location.href), origin: currentOrigin, clientId: clientInfo.clientId, appName: clientInfo.appInfo?.appName || "Unknown", @@ -2671,26 +3808,31 @@ if (window.checkExtensionLoaded) { redirectTo: redirectHostname, }); - // Send rogue_app_detected webhook - chrome.runtime.sendMessage({ - type: "send_webhook", - webhookType: "rogue_app_detected", - data: { - url: location.href, - clientId: clientInfo.clientId, - appName: clientInfo.appInfo?.appName || "Unknown", - reason: clientInfo.reason, - severity: "critical", - risk: "high", - description: clientInfo.appInfo?.description, - tags: clientInfo.appInfo?.tags || [], - references: clientInfo.appInfo?.references || [], - redirectTo: redirectHostname - } - }).catch(err => { - logger.warn("Failed to send rogue_app_detected webhook:", err.message); - }); - + // Send rogue_app_detected webhook + chrome.runtime + .sendMessage({ + type: "send_webhook", + webhookType: "rogue_app_detected", + data: { + url: defangUrl(location.href), + clientId: clientInfo.clientId, + appName: clientInfo.appInfo?.appName || "Unknown", + reason: clientInfo.reason, + severity: "critical", + risk: "high", + description: clientInfo.appInfo?.description, + tags: clientInfo.appInfo?.tags || [], + references: clientInfo.appInfo?.references || [], + redirectTo: redirectHostname, + }, + }) + .catch((err) => { + logger.warn( + "Failed to send rogue_app_detected webhook:", + err.message + ); + }); + return; } @@ -2726,7 +3868,7 @@ if (window.checkExtensionLoaded) { // Send CIPP reporting if enabled sendCippReport({ type: "microsoft_logon_detected", - url: location.href, + url: defangUrl(location.href), origin: currentOrigin, legitimate: true, timestamp: new Date().toISOString(), @@ -2745,7 +3887,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 +3910,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 +3957,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)" ); @@ -2849,6 +3988,18 @@ if (window.checkExtensionLoaded) { logger.warn( `🚨 PHISHING INDICATORS FOUND on non-Microsoft page: ${phishingResult.threats.length} threats` ); + // Log ALL detected threats + logger.log('πŸ“‹ Detailed threat breakdown:'); + phishingResult.threats.forEach((threat, idx) => { + logger.log( + ` ${idx + 1}. [${threat.severity.toUpperCase()}] ${threat.id} ` + + `(confidence: ${threat.confidence || 'N/A'})` + ); + logger.log(` ${threat.description}`); + if (threat.matchDetails) { + logger.log(` Matched in: ${threat.matchDetails}`); + } + }); // Check for critical threats that should be blocked regardless const criticalThreats = phishingResult.threats.filter( @@ -2875,14 +4026,14 @@ if (window.checkExtensionLoaded) { reason: reason, score: 0, // Critical threats get lowest score threshold: 85, - phishingIndicators: criticalThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.map((t) => t.id), }; if (protectionEnabled) { logger.error( "πŸ›‘οΈ PROTECTION ACTIVE: Blocking page due to critical phishing indicators" ); - showBlockingOverlay(reason, { + await showBlockingOverlay(reason, { threats: criticalThreats, score: phishingResult.score, }); @@ -2917,17 +4068,23 @@ if (window.checkExtensionLoaded) { clientId: clientInfo.clientId, clientSuspicious: clientInfo.isMalicious, clientReason: clientInfo.reason, - phishingIndicators: criticalThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.map((t) => t.id), }); sendCippReport({ type: "critical_phishing_blocked", - url: location.href, + url: defangUrl(location.href), reason: reason, severity: "critical", legitimate: false, timestamp: new Date().toISOString(), - phishingIndicators: criticalThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.map((t) => t.id), + matchedRules: criticalThreats.map((threat) => ({ + id: threat.id, + description: threat.description, + severity: threat.severity, + confidence: threat.confidence, + })), }); return; @@ -2968,7 +4125,7 @@ if (window.checkExtensionLoaded) { reason: reason, score: shouldEscalateToBlock ? 0 : 50, // Critical score if escalated threshold: 85, - phishingIndicators: warningThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.map((t) => t.id), escalated: shouldEscalateToBlock, escalationReason: shouldEscalateToBlock ? `${warningThreats.length} warning threats exceeded threshold of ${WARNING_THRESHOLD}` @@ -2984,7 +4141,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 +4174,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); @@ -3036,7 +4198,7 @@ if (window.checkExtensionLoaded) { clientId: clientInfo.clientId, clientSuspicious: clientInfo.isMalicious, clientReason: clientInfo.reason, - phishingIndicators: warningThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.map((t) => t.id), escalated: shouldEscalateToBlock, escalationReason: shouldEscalateToBlock ? `${warningThreats.length} warning threats exceeded threshold of ${WARNING_THRESHOLD}` @@ -3048,12 +4210,12 @@ if (window.checkExtensionLoaded) { type: shouldEscalateToBlock ? "escalated_threats_blocked" : "suspicious_content_detected", - url: location.href, + url: defangUrl(location.href), reason: reason, severity: shouldEscalateToBlock ? "critical" : "medium", legitimate: false, timestamp: new Date().toISOString(), - phishingIndicators: warningThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.map((t) => t.id), escalated: shouldEscalateToBlock, escalationReason: shouldEscalateToBlock ? `${warningThreats.length} warning threats exceeded threshold of ${WARNING_THRESHOLD}` @@ -3199,7 +4361,7 @@ if (window.checkExtensionLoaded) { // Send critical CIPP alert sendCippReport({ type: "critical_rogue_app_detected", - url: location.href, + url: defangUrl(location.href), origin: location.origin, clientId: clientInfo.clientId, appName: clientInfo.appInfo?.appName || "Unknown", @@ -3209,24 +4371,29 @@ if (window.checkExtensionLoaded) { }); // Send rogue_app_detected webhook - chrome.runtime.sendMessage({ - type: "send_webhook", - webhookType: "rogue_app_detected", - data: { - url: location.href, - clientId: clientInfo.clientId, - appName: clientInfo.appInfo?.appName || "Unknown", - reason: clientInfo.reason, - severity: "critical", - risk: "high", - description: clientInfo.appInfo?.description, - tags: clientInfo.appInfo?.tags || [], - references: clientInfo.appInfo?.references || [], - redirectTo: redirectHostname - } - }).catch(err => { - logger.warn("Failed to send rogue_app_detected webhook:", err.message); - }); + chrome.runtime + .sendMessage({ + type: "send_webhook", + webhookType: "rogue_app_detected", + data: { + url: location.href, + clientId: clientInfo.clientId, + appName: clientInfo.appInfo?.appName || "Unknown", + reason: clientInfo.reason, + severity: "critical", + risk: "high", + description: clientInfo.appInfo?.description, + tags: clientInfo.appInfo?.tags || [], + references: clientInfo.appInfo?.references || [], + redirectTo: redirectHostname, + }, + }) + .catch((err) => { + logger.warn( + "Failed to send rogue_app_detected webhook:", + err.message + ); + }); // Store detection result as critical threat lastDetectionResult = { @@ -3281,26 +4448,35 @@ if (window.checkExtensionLoaded) { logger.error( "πŸ›‘οΈ PROTECTION ACTIVE: Blocking page - redirecting to blocking page" ); - + // Send page_blocked webhook - chrome.runtime.sendMessage({ - type: "send_webhook", - webhookType: "page_blocked", - data: { - url: location.href, - reason: blockingResult.reason, - severity: blockingResult.severity || "critical", - score: 0, - threshold: blockingResult.threshold || 85, - rule: blockingResult.rule?.id || "blocking_rule", - ruleDescription: blockingResult.reason, - timestamp: new Date().toISOString() - } - }).catch(err => { - logger.warn("Failed to send page_blocked webhook:", err.message); - }); - - showBlockingOverlay(blockingResult.reason, blockingResult); + chrome.runtime + .sendMessage({ + type: "send_webhook", + webhookType: "page_blocked", + data: { + url: defangUrl(location.href), + reason: blockingResult.reason, + severity: blockingResult.severity || "critical", + score: 0, + threshold: blockingResult.threshold || 85, + rule: blockingResult.rule?.id || "blocking_rule", + ruleDescription: blockingResult.reason, + matchedRules: [ + { + id: blockingResult.rule?.id || "blocking_rule", + description: blockingResult.reason, + severity: blockingResult.severity || "critical", + }, + ], + timestamp: new Date().toISOString(), + }, + }) + .catch((err) => { + logger.warn("Failed to send page_blocked webhook:", err.message); + }); + + await showBlockingOverlay(blockingResult.reason, blockingResult); disableFormSubmissions(); disableCredentialInputs(); stopDOMMonitoring(); @@ -3340,7 +4516,7 @@ if (window.checkExtensionLoaded) { // Send CIPP reporting if enabled sendCippReport({ type: "phishing_blocked", - url: location.href, + url: defangUrl(location.href), reason: blockingResult.reason, rule: blockingResult.rule?.id, severity: blockingResult.severity, @@ -3397,26 +4573,33 @@ if (window.checkExtensionLoaded) { logger.error( "πŸ›‘οΈ PROTECTION ACTIVE: Blocking due to critical detection rule" ); - + // Send page_blocked webhook - chrome.runtime.sendMessage({ - type: "send_webhook", - webhookType: "page_blocked", - data: { - url: location.href, - reason: reason, - severity: "critical", - score: 0, - threshold: detectionResult.threshold, - rule: criticalBlockingRules[0]?.id || "critical_rule", - ruleDescription: reason, - timestamp: new Date().toISOString() - } - }).catch(err => { - logger.warn("Failed to send page_blocked webhook:", err.message); - }); - - showBlockingOverlay(reason, { + chrome.runtime + .sendMessage({ + type: "send_webhook", + webhookType: "page_blocked", + data: { + url: defangUrl(location.href), + reason: reason, + severity: "critical", + score: 0, + threshold: detectionResult.threshold, + rule: criticalBlockingRules[0]?.id || "critical_rule", + ruleDescription: reason, + matchedRules: criticalBlockingRules.map((rule) => ({ + id: rule.id, + description: rule.description, + severity: "critical", + })), + timestamp: new Date().toISOString(), + }, + }) + .catch((err) => { + logger.warn("Failed to send page_blocked webhook:", err.message); + }); + + await showBlockingOverlay(reason, { threats: criticalBlockingRules.map((rule) => ({ description: rule.description, severity: "critical", @@ -3458,7 +4641,7 @@ if (window.checkExtensionLoaded) { sendCippReport({ type: "critical_detection_blocked", - url: location.href, + url: defangUrl(location.href), reason: reason, severity: "critical", legitimate: false, @@ -3528,33 +4711,46 @@ if (window.checkExtensionLoaded) { reason: reason, score: 0, // Critical threats get lowest score threshold: detectionResult.threshold, - phishingIndicators: criticalThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.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" ); - + // Send page_blocked webhook - chrome.runtime.sendMessage({ - type: "send_webhook", - webhookType: "page_blocked", - data: { - url: location.href, - reason: reason, - severity: "critical", - score: 0, - threshold: detectionResult.threshold, - rule: criticalThreats[0]?.id || "critical_phishing", - ruleDescription: reason, - timestamp: new Date().toISOString() - } - }).catch(err => { - logger.warn("Failed to send page_blocked webhook:", err.message); - }); - - showBlockingOverlay(reason, { + chrome.runtime + .sendMessage({ + type: "send_webhook", + webhookType: "page_blocked", + data: { + url: defangUrl(location.href), + reason: reason, + severity: "critical", + score: 0, + threshold: detectionResult.threshold, + rule: criticalThreats[0]?.id || "critical_phishing", + ruleDescription: reason, + matchedRules: criticalThreats.map((threat) => ({ + id: threat.id, + description: threat.description, + severity: threat.severity, + confidence: threat.confidence, + })), + timestamp: new Date().toISOString(), + }, + }) + .catch((err) => { + logger.warn("Failed to send page_blocked webhook:", err.message); + }); + + await showBlockingOverlay(reason, { threats: criticalThreats, score: phishingResult.score, }); @@ -3589,17 +4785,17 @@ if (window.checkExtensionLoaded) { clientId: clientInfo.clientId, clientSuspicious: clientInfo.isMalicious, clientReason: clientInfo.reason, - phishingIndicators: criticalThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.map((t) => t.id), }); sendCippReport({ type: "critical_phishing_blocked", - url: location.href, + url: defangUrl(location.href), reason: reason, severity: "critical", legitimate: false, timestamp: new Date().toISOString(), - phishingIndicators: criticalThreats.map((t) => t.id), + phishingIndicators: phishingResult.threats.map((t) => t.id), }); return; @@ -3634,7 +4830,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, }); @@ -3670,7 +4866,7 @@ if (window.checkExtensionLoaded) { sendCippReport({ type: "low_score_blocked", - url: location.href, + url: defangUrl(location.href), reason: reason, severity: "high", legitimate: false, @@ -3725,26 +4921,45 @@ if (window.checkExtensionLoaded) { logger.error( "πŸ›‘οΈ PROTECTION ACTIVE: Blocking page due to high threat" ); - + // Send page_blocked webhook - chrome.runtime.sendMessage({ - type: "send_webhook", - webhookType: "page_blocked", - data: { - url: location.href, - reason: reason, - severity: severity, - score: detectionResult.score, - threshold: detectionResult.threshold, - rule: detectionResult.triggeredRules?.[0] || "unknown", - ruleDescription: detectionResult.triggeredRules?.[0] || reason, - timestamp: new Date().toISOString() - } - }).catch(err => { - logger.warn("Failed to send page_blocked webhook:", err.message); - }); - - showBlockingOverlay(reason, lastDetectionResult); + chrome.runtime + .sendMessage({ + type: "send_webhook", + webhookType: "page_blocked", + data: { + url: defangUrl(location.href), + reason: reason, + severity: severity, + score: detectionResult.score, + threshold: detectionResult.threshold, + rule: detectionResult.triggeredRules?.[0] || "unknown", + ruleDescription: + detectionResult.triggeredRules?.[0] || reason, + matchedRules: [ + ...(detectionResult.triggeredRules?.map((rule) => ({ + id: rule, + description: rule, + severity: "medium", + })) || []), + ...phishingResult.threats.map((threat) => ({ + id: threat.id, + description: threat.description, + severity: threat.severity, + confidence: threat.confidence, + })), + ], + timestamp: new Date().toISOString(), + }, + }) + .catch((err) => { + logger.warn( + "Failed to send page_blocked webhook:", + err.message + ); + }); + + await showBlockingOverlay(reason, lastDetectionResult); disableFormSubmissions(); disableCredentialInputs(); stopDOMMonitoring(); // Stop monitoring once blocked @@ -3761,6 +4976,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 +5000,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); @@ -3805,7 +5030,7 @@ if (window.checkExtensionLoaded) { // Send CIPP reporting if enabled sendCippReport({ type: "suspicious_logon_detected", - url: location.href, + url: defangUrl(location.href), threatLevel: severity, reason: reason, score: detectionResult.score, @@ -3856,7 +5081,7 @@ if (window.checkExtensionLoaded) { // Send CIPP reporting for legitimate access on non-trusted domain sendCippReport({ type: "microsoft_logon_detected", - url: location.href, + url: defangUrl(location.href), origin: location.origin, legitimate: true, nonTrustedDomain: true, @@ -3915,6 +5140,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 @@ -3933,6 +5159,12 @@ if (window.checkExtensionLoaded) { 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 +5174,16 @@ 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 +5285,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 +5295,35 @@ 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, false, { scanCleaned: true }); + domScanTimeout = null; + }, 1000); } else if (newElementsAdded) { logger.debug( "πŸ” DOM changes detected but not significant enough to re-run analysis" @@ -4085,10 +5343,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, false, { scanCleaned: true }); + clearInterval(checkInterval); return; } @@ -4108,6 +5376,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 +5398,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 +5413,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 +5470,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 +5488,48 @@ 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
- - - `; + `; + + document.body.appendChild(overlay); - logger.log("Fallback page content replacement completed"); + // 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 +5653,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 +5670,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 +5695,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 +5735,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 +5759,11 @@ if (window.checkExtensionLoaded) { subject )}&body=${body}`; }); + + // CRITICAL: Register contact elements + registerInjectedElement(contactDiv); + registerInjectedElement(contactLink); + contactDiv.appendChild(contactLink); textWrap.appendChild(contactDiv); } @@ -4539,19 +5810,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 +5845,37 @@ 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 +5885,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 +5896,30 @@ 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 +5956,7 @@ if (window.checkExtensionLoaded) { Verified Microsoft Domain
This is an authentic Microsoft login page
-