# Project export: PhishLens

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: PhishLens stops phishing before submission, with transparent evidence users can understand and trust.
- Devpost: https://devpost.com/software/phishlens
- GitHub: https://github.com/its-gianandre/PhishLens
- Video: https://www.youtube.com/embed/F_P5vclWDSU?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — its-gianandre (26 commits), Saumit Guduguntla (8 commits), Augustin Birladeanu (3 commits)

## Devpost submission (written by the team)

### Inspiration

Several members of our team are cybersecurity engineering students, so we wanted to build something connected to the problems we study. Phishing affects both everyday users and developers, yet many security tools provide warnings without explaining the evidence behind them. We created PhishLens to make phishing protection understandable, transparent, and actionable.

### What it does

PhishLens is a browser extension that analyzes webpages in real time for signs of phishing and malware. It evaluates suspicious URLs, brand-domain mismatches, sensitive forms, cross-domain submissions, social-engineering language, and known-threat intelligence. The extension produces a transparent risk score, identifies the evidence behind it, and recommends a safe action. It can also mark suspicious links and interrupt risky form submissions before credentials are sent. Its explanation layer translates technical findings into clear language without changing the underlying score or inventing evidence.

### How we built it

We built PhishLens as a Manifest V3 browser extension using TypeScript. A content script extracts privacy-conscious page and form evidence, independent detectors convert that evidence into structured security signals, and a deterministic scoring engine produces a risk level from 0 to 100. PhishLens combines local threat data with optional server-side threat-intelligence indexes. Its backend runs on AWS and provides evidence-based explanations with a deterministic fallback when the AI service is unavailable. We used Codex powered by GPT‑5.6 throughout development to help design the architecture, implement and debug features, create safe phishing simulations, review privacy boundaries, and develop unit, integration, and adversarial tests. Our team reviewed and verified the resulting code and security behavior.

### Challenges we ran into

One major challenge was keeping the extension dynamic as webpages changed, especially when forms or links were inserted after the initial page load. We also had to normalize URLs and synchronize multiple threat-intelligence datasets while preventing duplicate matches from unfairly increasing the score. Another challenge was making AI-generated explanations detailed enough for technical users while remaining understandable to beginners. We addressed this by separating deterministic detection from explanation generation: the AI can clarify validated findings, but it cannot alter the score or introduce unsupported claims.

### Accomplishments we're proud of

We are proud that PhishLens goes beyond simply labeling a page as dangerous. It connects every warning to concrete, reviewable evidence and can intervene before sensitive information is submitted. We also successfully connected the extension to an AWS-hosted backend, integrated multiple threat-intelligence sources, added proactive link protection, and designed graceful fallbacks so core detection continues working when external services are unavailable.

### What we learned

We learned how phishing attack surfaces appear to everyday users and how attackers combine urgency, impersonation, misleading URLs, credential forms, and other social-engineering techniques. We also learned that effective security tools must balance accuracy, privacy, usability, and explainability. A warning is significantly more useful when users understand what triggered it and what they should do next.

### What's next

Our next goal is to prepare PhishLens for public release as a free browser extension. Before publishing it, we plan to strengthen transport security, expand testing against more real-world patterns, improve accessibility, and establish a reliable process for updating threat-intelligence datasets. Longer-term possibilities include additional browser support, more advanced detection techniques, and optional tools for security teams—while preserving the privacy-conscious and evidence-based design at the core of PhishLens.

## README (from the GitHub repository)

# PhishLens 🔎

*Project for OpenAI's Build Week.*

A Chrome (Manifest V3) extension that detects phishing websites in real time
using **rule-based detection plus evidence-based explanations**. It answers four questions
about every page:

1. What organization does the page claim to represent?
2. Does the actual domain match that organization?
3. Is the page collecting sensitive information?
4. What concrete evidence makes it suspicious?

The output is a transparent 0–100 risk score, a classification
(Low / Caution / High / Critical), and a plain-language + technical
explanation — never a black-box verdict.

```
Webpage → Evidence extraction → Rule-based detection → Risk scoring
        → AWS-hosted backend → Optional Ollama summary → Warning and recommended action
```

## Quick start

For installation, Chrome loading, test pages, and optional backend instructions,
see the **[setup guide](SETUP.md)**.

```bash
npm install
npm run build        # bundles the extension into ./dist
npm test             # unit / integration / adversarial tests
npm run test-pages   # harmless phishing simulations on http://localhost:8000
npm run backend      # optional local backend for development on 127.0.0.1:8787
npm run threat-intel:test-feed  # verify the local PhishTank feed
```

Load the extension: `chrome://extensions` → enable **Developer mode** →
**Load unpacked** → select the `dist/` folder. Then open
`http://localhost:8000/` and click through the test pages
(expectations documented in `test-pages/EXPECTED.md`).

For a presentation-ready view of every threat-intelligence provider at once,
open `http://intel-showcase.localhost:8000/threat-intel-showcase.html`. Its
matches are explicitly scoped to this harmless localhost fixture, so the
showcase works without changing or redeploying the AWS service.

## AWS-hosted explanations

The extension is configured to call the backend at `http://18.220.29.188:8787`.
That backend runs in AWS and is configured to reach an Ollama service. The
repository does not assume whether Ollama shares the same server or runs in a
separate AWS service; only `OLLAMA_URL` and `OLLAMA_MODEL` connect the two.

Repository updates do not deploy themselves. After publishing this revision to
AWS, verify that `GET /health` includes `providers.openphish` and reports
`ollama.configured: true`. The checked-in endpoint currently uses plain HTTP;
place it behind HTTPS before using PhishLens for non-demo browsing so lookup
traffic and backend responses cannot be observed or altered in transit.

The backend first builds a deterministic explanation from validated detector
signals. When `OLLAMA_URL` is configured and reachable, Ollama rewrites only
the short summary. It never receives raw page text, signal descriptions, or
evidence snippets, and it cannot change the score, reasons, recommended action,
limitations, or citations. If Ollama is unset, times out, or returns an invalid
response, the deterministic template summary is returned automatically.

For local backend development, optionally point `OLLAMA_URL` at a reachable
Ollama server before starting the process:

```powershell
$env:OLLAMA_URL = 'http://127.0.0.1:11434'
$env:OLLAMA_MODEL = 'llama3.2:3b'
npm run backend
```

The extension continues to use `BACKEND_ORIGIN` from
`extension/shared/constants.ts`; change that value and rebuild only when you
specifically want the extension to exercise a local backend.

## Threat intelligence

PhishLens combines one extension-local domain list with three optional server-side indexes:

- **Block List Project** identifies phishing hostnames from a bundled domain-only snapshot.
  Lookups happen entirely inside the extension and continue to work without the backend.

- **PhishTank** identifies verified phishing URLs from the bundled immutable snapshot.
- **URLhaus** identifies malware-distribution URLs from an authenticated recent CSV export.
- **OpenPhish** identifies phishing URLs from its public community feed, refreshed when the
  backend starts and cached for offline fallback.

The extension also uses the complete Public Suffix List, including private hosting suffixes, to
identify registrable domains correctly for brand checks, trusted-domain overrides, forms, and
link analysis. The PSL and Block List Project snapshot are packaged data, not runtime services;
neither requires an API key, AWS access, or a network request while browsing.

### Local dataset updates

The committed Block List Project snapshot is generated from its domain-only phishing list and
validated before it can replace the previous copy. Refresh it deliberately before an extension
release:

```bash
npm run datasets:update
npm run build
```

The generated file records its source, retrieval time, SHA-256 hash, license, and entry count.
Malformed, empty, or oversized downloads are rejected. Exact hostname and parent-domain matches
can contribute the existing phishing-feed signal and can stop a known-phishing link; duplicate
matches from PhishTank or OpenPhish still produce only one scored signal. The popup displays the
Block List Project card only when it reports a match.

The browser extension never contains provider credentials and never visits a URL from a feed.
For the optional server-side providers, it sends a privacy-sanitized URL to the configured
backend, which checks its indexes without visiting the submitted destination.

### URLhaus setup

Obtain an Auth-Key from the abuse.ch Authentication Portal, then provide it only to the backend
process. In PowerShell:

```powershell
$urlhausSecureKey = Read-Host "URLhaus Auth-Key" -AsSecureString
$env:URLHAUS_AUTH_KEY = [System.Net.NetworkCredential]::new("", $urlhausSecureKey).Password
npm run backend
```

Enter the key only after PowerShell displays the masked prompt. Do not replace the prompt label
`"URLhaus Auth-Key"` with the key itself; doing that exposes the credential in terminal history.

On startup, the backend downloads the official URLhaus `recent.csv` export, validates and indexes
it, and writes an ignored local cache to `backend/threat-intel/data/urlhaus-recent.csv`. Later
starts can use that cache when no key is configured. Never commit the key, put it in extension
settings, or paste it into browser code. The key-bearing export URL is deliberately excluded from
logs and error messages.

Only exact URLhaus matches whose feed status is `online` affect scoring. Exact offline matches and
hostname-only matches remain visible as supporting context but do not independently add points.

### OpenPhish setup

OpenPhish requires no API key. When `includeOpenPhish` is enabled (the default backend command
enables it), startup downloads `https://www.openphish.com/feed.txt`, validates the URLs, and writes
the ignored cache `backend/threat-intel/data/openphish-feed.txt`. A failed refresh falls back to a
valid cache. Empty, malformed, or oversized downloads are rejected rather than replacing a valid
provider. The safe presentation entries in `demo-openphish.txt` are overlaid separately.

An exact OpenPhish URL match contributes the existing `known-malicious-url` signal. If PhishTank
and OpenPhish both contain the same URL, PhishLens emits that scored signal only once. Hostname-only
matches remain informational.

### PhishTank snapshot

PhishLens includes a dated, immutable PhishTank snapshot at:

```text
backend/threat-intel/data/phishtank-snapshot-2026-07-16.json.gz
```

This snapshot is deliberately committed so the integration works without a
PhishTank account or application key. Other downloaded feed files remain
ignored by Git. Provenance, hashes, counts, and limitations are documented in
`backend/threat-intel/SNAPSHOT.md`.

Verify the bundled snapshot before starting the extension:

```bash
npm run threat-intel:test-feed
npm run backend
```

The backend decompresses and indexes the snapshot once at startup. `GET /health`
reports whether each provider is available and the number of indexed URLs and
hostnames without exposing feed paths.

Pa

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 66 recognized source files, 260 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- TypeScript (language) — detected in the code
- AWS (technology) — claimed on Devpost, not found in the code
- JavaScript (language) — claimed on Devpost, not found in the code
- Ollama (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (94 of 94)

```
.gitignore
backend/demo-server.mjs
backend/explain.mjs
backend/server.mjs
backend/threat-intel/data/.gitkeep
backend/threat-intel/data/demo-openphish.txt
backend/threat-intel/data/demo-phishtank.json
backend/threat-intel/data/demo-urlhaus.csv
backend/threat-intel/index.mjs
backend/threat-intel/normalize-url.mjs
backend/threat-intel/parser.mjs
backend/threat-intel/providers/openphish.mjs
backend/threat-intel/providers/phishtank.mjs
backend/threat-intel/SNAPSHOT.md
backend/threat-intel/test-feed.mjs
backend/threat-intel/urlhaus/download.mjs
backend/threat-intel/urlhaus/parser.mjs
backend/threat-intel/urlhaus/provider.mjs
build.mjs
extension/background/enrichment.ts
extension/background/link-analysis.ts
extension/background/pipeline.ts
extension/background/service-worker.ts
extension/content/analyze-forms.ts
extension/content/extract-page.ts
extension/content/index.ts
extension/content/link-protection.ts
extension/content/warning-banner.ts
extension/data/blocklist-project-phishing.txt
extension/data/demo-blocklist-project.txt
extension/detectors/brand-detector.ts
extension/detectors/form-detector.ts
extension/detectors/language-detector.ts
extension/detectors/threat-intel-result.ts
extension/detectors/url-detector.ts
extension/manifest.json
extension/popup/popup.css
extension/popup/popup.html
extension/popup/popup.ts
extension/popup/threat-context.ts
extension/scoring/calculate-risk.ts
extension/shared/constants.ts
extension/shared/domain.ts
extension/shared/sanitize-link-url.ts
extension/shared/settings.ts
extension/shared/signals.ts
extension/shared/types.ts
extension/threat-intel/blocklist-project.ts
extension/threat-intel/presentation-fixtures.ts
package.json
README.md
scripts/update-local-datasets.mjs
SETUP.md
test-pages/blocklist-demo.html
test-pages/combined-phish.html
test-pages/critical-browser-update.html
test-pages/demo.css
test-pages/EXPECTED.md
test-pages/external-form.html
test-pages/fake-microsoft-login.html
test-pages/index.html
test-pages/link-protection.html
test-pages/normal-login.html
test-pages/openphish-demo.html
test-pages/serve.mjs
test-pages/threat-intel-showcase.html
test-pages/urgency-page.html
test-pages/vendor-status.html
test-pages/verified-apple-id.html
tests/adversarial.test.ts
tests/backend.test.ts
tests/blocklist-project.test.ts
tests/brand-detector.test.ts
tests/demo-pages.test.ts
tests/domain.test.ts
tests/fixtures/empty-openphish.txt
tests/fixtures/phishtank-small.json
tests/fixtures/urlhaus-small.csv
tests/form-detector.test.ts
tests/helpers.ts
tests/language-detector.test.ts
tests/link-analysis.test.ts
tests/link-collection.test.ts
tests/openphish.test.ts
tests/pipeline.test.ts
tests/sanitize-link-url.test.ts
tests/scoring.test.ts
tests/threat-context.test.ts
tests/threat-intel-backend.test.ts
tests/threat-intel-enrichment.test.ts
tests/url-detector.test.ts
tests/urlhaus.test.ts
tsconfig.json
vitest.config.ts
```

### Dependencies

- package.json: @types/chrome@^0.2.2, @types/jsdom@^28.0.3, esbuild@^0.28.1, jsdom@^29.1.1, tldts@^7.4.9, typescript@^7.0.2, vitest@^4.1.10

### Recent commits (newest first)

- Update README.md
- Created SETUP.md
- Added BlockList, Open Suffix List, and new test pages for presentation
- Debugged threat intel feed display
- Improved Ollama and Openphish synchronization
- Merge pull request #2 from its-gianandre/fix/openphish-provider-import
- fix: resolve openphish import path and default initialization
- Merge pull request #1 from saumitg26/feature/openphish-integration
- Merge upstream main and resolve conflicts in threat intel
- Add OpenPhish threat intel feed support and demo page
- Add Ollama-backed explanations and point the extension at the EC2 backend
- Update README.md
- Delete DEMO.md
- Remove OpenPhish provider integration
- Delete backend/threat-intel/providers/openfish.mjs
- Integrate OpenPhish provider into threat intel module
- Add OpenPhish provider for threat intelligence
- Created new test page to demonstrate embedded link scanning
- Merge branch 'main' of https://github.com/its-gianandre/PhishLens
- Implemented proactive link protection across PhishLens.

## Key source files (fetched from GitHub, selected and truncated for size)

### SETUP.md

```markdown
# PhishLens setup guide

## Prerequisites

Install:

- Node.js 20 or newer, including npm
- Google Chrome 120 or newer
- Git, if you are cloning the repository

Confirm Node.js and npm are available:

```text
node --version
npm --version
```

On Windows PowerShell, use `npm.cmd` instead of `npm` if script execution is
blocked. For example, run `npm.cmd ci`.

## Install and build

Open a terminal in the repository folder containing `package.json`, then run:

```text
npm ci
npm run typecheck
npm test
npm run build
```

The completed extension is written to `dist/`.

## Load the extension in Chrome

1. Open `chrome://extensions`.
2. Enable **Developer mode**.
3. Select **Load unpacked**.
4. Choose the repository's `dist` folder.
5. Pin PhishLens from Chrome's Extensions menu if desired.

After changing extension code:

1. Run `npm run build`.
2. Reload PhishLens on `chrome://extensions`.
3. Refresh the webpage being tested.

Chrome internal pages and the Chrome Web Store cannot be analyzed by the
extension. Use a regular `http://` or `https://` page.

## Run the test pages

Start the harmless local presentation pages:

```text
npm run test-pages
```

Keep that terminal open, then visit:

```text
http://localhost:8000/
```

The gallery includes:

- Original Low, Caution, High, and Critical detector examples
- Individual PhishTank, URLhaus, OpenPhish, and Block List Project examples
- A combined page where all four threat-intelligence providers display a match
- A proactive link-protection demonstration

Useful direct links:

| Scenario | URL |
| --- | --- |
| All four providers | `http://intel-showcase.localhost:8000/threat-intel-showcase.html` |
| PhishTank | `http://signin-portal.localhost:8000/verified-apple-id.html` |
| URLhaus | `http://software-update.localhost:8000/critical-browser-update.html` |
| OpenPhish | `http://localhost:8000/openphish-demo.html` |
| Block List Project | `http://blocklist-demo.localhost:8000/blocklist-demo.html` |
| Link protection | `http://social-feed.localhost:8000/link-protection.html` |

All test pages and presentation matches are safe local fixtures. Do not enter
real credentials when testing form warnings.

Detailed expected behavior is documented in
[`test-pages/EXPECTED.md`](test-pages/EXPECTED.md).

## Backend configuration — optional

Page analysis and scoring run locally in the extension. The backend provides
explanations and the normal PhishTank, URLhaus, and OpenPhish indexes. Block
List Project and Public Suffix List checks run inside the extension.

The configured backend address is `BACKEND_ORIGIN` in:

```text
extension/shared/constants.ts
```

To run the backend locally:

```text
npm run backend
```

It listens on `http://127.0.0.1:8787` by default. Check its status at:

```text
http://127.0.0.1:8787/health
```

To use the local backend from the extension:

1. Change `BACKEND_ORIGIN` to `http://127.0.0.1:8787`.
2. Run `npm run build`.
3. Reload the extension.
4. Refresh the test page.

The backend rea
[truncated — 3505 more characters]
```

### test-pages/EXPECTED.md

```markdown
# Expected results for the test pages

Serve with `npm run test-pages`, then open `http://localhost:8000/`. All pages
are harmless simulations with dummy endpoints — use only fake credentials.
The original detector expectations are enforced by `tests/pipeline.test.ts`;
the presentation scenarios are enforced by `tests/demo-pages.test.ts`.

Scores assume default settings (threat intel on, no approved-domain overrides).
Loopback hosts are exempt from HTTP/port signals, so scores come from content,
not from the pages being served locally.


## Explanation UI checks

Ensure the configured backend is reachable. The checked-in build uses the
AWS-hosted backend; for local backend testing, update `BACKEND_ORIGIN`, rebuild,
and start it with `npm run backend`. For each page, open the popup and click
*Explain this result*. Verify that:

- the button is disabled while *Generating explanation...* is displayed and
  changes to *Regenerate explanation* after a result appears;
- an Ollama response is labelled *Evidence-based explanation (AI-polished
  summary)*, while a fallback is labelled *Evidence-based template explanation*;
- the explanation is based only on the detected structured findings;
- *Evidence citations* expands to show the signal id, description, and exact
  supporting evidence for every detected signal; and
- Technical mode adds the score breakdown plus expandable *Technical details
  and limitations* without covering the popup content while scrolling.

## 1. normal-login.html — expected: **Low** (score 0–29; approximately 13)

| Expected signal | Why |
| --- | --- |
| `password-field` (+5) | Ordinary login form |
| `suspicious-url-keyword` (+8) | "login" appears in the page URL |

Same-origin form action, no brand claims, no manipulative language. Expected
recommendation: verify the address before signing in or entering sensitive
information. The explanation should describe the two weak indicators without
calling the page safe. Two evidence citations should appear. No banner or
submission guard is expected.

## 2. fake-microsoft-login.html — expected: **High** (score 60–79; approximately 68)

| Expected signal | Why |
| --- | --- |
| `brand-domain-mismatch` (+20) | Claims Microsoft; hosted on localhost |
| `password-field` (+5) | Credential form |
| `suspicious-url-keyword` (+8) | "login" in URL |
| combo: brand mismatch + password (+35) | Impersonation collecting credentials |

The explanation should state that the page appears to impersonate Microsoft
and connect that warning to the mismatched domain and password request. Three
evidence citations should appear. The recommended action should tell the user
not to enter passwords, codes, or payment details and to use an official app
or bookmark. Banner shows; submission guard active.

## 3. external-form.html — expected: **Caution** (score 30–59; approximately 45)

| Expected signal | Why |
| --- | --- |
| `password-field` (+5) | Credential form |
| `external-form-action` (+25) | Posts to 127.0
[truncated — 8385 more characters]
```

### package.json

```
{
  "name": "phishlens",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Chrome MV3 extension that detects phishing websites in real time using rule-based detection plus evidence-based explanations.",
  "scripts": {
    "build": "node build.mjs",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:watch": "vitest",
    "backend": "node backend/server.mjs",
    "backend:demo": "node backend/demo-server.mjs",
    "datasets:update": "node scripts/update-local-datasets.mjs",
    "threat-intel:test-feed": "node backend/threat-intel/test-feed.mjs",
    "test-pages": "node test-pages/serve.mjs"
  },
  "devDependencies": {
    "@types/chrome": "^0.2.2",
    "@types/jsdom": "^28.0.3",
    "esbuild": "^0.28.1",
    "jsdom": "^29.1.1",
    "typescript": "^7.0.2",
    "vitest": "^4.1.10"
  },
  "dependencies": {
    "tldts": "^7.4.9"
  }
}

```

### backend/server.mjs

```
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { explain } from './explain.mjs';
import { getThreatIntelService, initializeThreatIntel } from './threat-intel/index.mjs';
import { normalizeUrl } from './threat-intel/normalize-url.mjs';

const PORT = Number(process.env.PORT ?? 8787);
const HOST = process.env.HOST ?? '127.0.0.1';
const MAX_BODY_BYTES = 100 * 1024;
const MAX_URL_LENGTH = 4096;
const MAX_BATCH_URLS = 100;

function allowedOrigin(origin) {
  if (!origin) return null;
  try {
    const url = new URL(origin);
    if (
      url.protocol === 'chrome-extension:' &&
      /^[a-p]{32}$/.test(url.hostname) &&
      (!url.pathname || url.pathname === '/')
    ) {
      return origin;
    }
    if (
      (url.protocol === 'http:' || url.protocol === 'https:') &&
      (url.hostname === '127.0.0.1' || url.hostname === 'localhost') &&
      url.pathname === '/'
    ) {
      return origin;
    }
  } catch {
    // Invalid origins are not allowed.
  }
  return null;
}

function sendJson(req, res, status, payload) {
  const headers = {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Headers': 'Content-Type',
    'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
    'Cache-Control': 'no-store',
    Vary: 'Origin',
  };
  const origin = allowedOrigin(req.headers.origin);
  if (origin) headers['Access-Control-Allow-Origin'] = origin;
  res.writeHead(status, headers);
  res.end(JSON.stringify(payload));
}

function hasJsonContentType(req) {
  const contentType = req.headers['content-type'];
  return typeof contentType === 'string' &&
    contentType.split(';', 1)[0].trim().toLowerCase() === 'application/json';
}

function readJsonBody(req, res) {
  return new Promise((resolve, reject) => {
    let size = 0;
    const chunks = [];
    req.on('data', (chunk) => {
      size += chunk.length;
      if (size > MAX_BODY_BYTES) {
        reject(Object.assign(new Error('payload too large'), { status: 413 }));
        return;
      }
      if (size <= MAX_BODY_BYTES) chunks.push(chunk);
    });
    req.on('error', reject);
    req.on('end', () => {
      try {
        resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
      } catch {
        reject(Object.assign(new Error('body must be valid JSON'), { status: 400 }));
      }
    });
  });
}

export function createBackendServer() {
  return http.createServer(async (req, res) => {
    const origin = req.headers.origin;
    if (origin && !allowedOrigin(origin)) {
      sendJson(req, res, 403, { error: 'origin not allowed' });
      return;
    }

    if (req.method === 'OPTIONS') {
      sendJson(req, res, 204, {});
      return;
    }

    if (req.method === 'GET' && req.url === '/health') {
      sendJson(req, res, 200, {
        ok: true,
        mode: 'self-hosted',
        threatIntel: getThreatIntelService().health(),
        ollama: { configured: Boolean(process.env.OLLAMA_URL) },
      });
      return;
    }

    if (req.method === 'POST' && req.url === '/explain') {
      if (!hasJsonContentType(req)) {
        sendJson(req, res, 415, { error: 'content type must be application/json' });
        return;
      }
      try {
        const body = await readJsonBody(req, res);
        const explanation = await explain(body);
        sendJson(req, res, 200, explanation);
      } catch (error) {
        sendJson(req, res, error?.status ?? 400, {
          error: String(error?.message ?? error),
        });
      }
      return;
    }

    if (req.method === 'POST' && req.url === '/threat-intel') {
      if (!hasJsonContentType(req)) {
        sendJson(req, res, 415, { error: 'content type must be application/json' });
        return;
      }
      try {
        const body = await readJsonBody(req, res);
        if (typeof body?.url !== 'string') throw new Error('url must be a string');
        if (body.url.length > MAX_URL_LENGTH) throw new Error('url exceeds 4096 characters');
        normalizeUrl(body.url);
        sendJson(req, res, 200, getThreatIntelService().lookup(body.url));
      } catch (error) {
        sendJson(req, res, error?.status ?? 400, {
          error: String(error?.message ?? error),
        });
      }
      return;
    }

    if (req.method === 'POST' && req.url === '/threat-intel/batch') {
      if (!hasJsonContentType(req)) {
        sendJson(req, res, 415, { error: 'content type must be application/json' });
        return;
      }
      try {
        const body = await readJsonBody(req, res);
        if (!Array.isArray(body?.urls)) throw new Error('urls must be an array');
        if (body.urls.length > MAX_BATCH_URLS) throw new Error('batch exceeds 100 URLs');
        for (const url of body.urls) {
          if (typeof url !== 'string') throw new Error('every URL must be a string');
          if (url.length > MAX_URL_LENGTH) throw new Error('url exceeds 4096 characters');
          normalizeUrl(url);
        }
        sendJson(req, res, 200, {
          results: body.urls.map((url) => getThreatIntelService().lookup(url)),
        });
      } catch (error) {
        sendJson(req, res, error?.status ?? 400, {
          error: String(error?.message ?? error),
        });
      }
      return;
    }

    sendJson(req, res, 404, { error: 'not found' });
  });
}

export async function startBackend(port = PORT, threatIntelOptions = { includeDemoFixtures: true, includeOpenPhish: true }) {
  const threatIntel = await initializeThreatIntel(threatIntelOptions);
  const server = createBackendServer();
  await new Promise((resolve) => server.listen(port, HOST, resolve));
  console.log(`PhishLens backend listening on http://${HOST}:${port}`);
  const readyProviders = Object.entries(threatIntel.providers)
    .filter(([, provider]) => provider.available)
    .map(([name, provider]) => `${name}: ${provider.records} URLs`);
  console.log(readyProviders.length
    ? `Threat intelligence ready (${readyProviders.join(', ')}).`
    : 'Threat intelligence unavailable; local analys
[truncated — 354 more characters]
```

### extension/content/index.ts

```typescript
import type {
  AnalysisResult,
  AnalyzeResponse,
  ContentConfig,
  ResultUpdatedMessage,
} from '../shared/types';
import { extractPageEvidence } from './extract-page';
import { installLinkProtection } from './link-protection';
import { installSubmitGuard, showWarningBanner } from './warning-banner';

let contentConfig: ContentConfig | null = null;

function applyResult(result: AnalysisResult): void {
  if (result.url !== location.href || !contentConfig) return;
  if (result.score >= contentConfig.bannerThreshold) {
    showWarningBanner(result);
  }
  if (contentConfig.submissionWarnings && result.score >= contentConfig.guardThreshold) {
    installSubmitGuard(result);
  }
}

chrome.runtime.onMessage.addListener((message: ResultUpdatedMessage) => {
  if (message?.type === 'RESULT_UPDATED') applyResult(message.result);
});

(async () => {
  try {
    const evidence = extractPageEvidence();
    const response: AnalyzeResponse | undefined = await chrome.runtime.sendMessage({
      type: 'ANALYZE',
      evidence,
    });
    if (!response?.result) return;

    const { result, config } = response;
    contentConfig = config;
    applyResult(result);
    if (config.linkProtection) installLinkProtection();
  } catch {
    // Extension context invalidated (e.g. reload during analysis) — nothing to do.
  }
})();

```

### backend/threat-intel/index.mjs

```
import { fileURLToPath } from 'node:url';
import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { buildPhishTankIndex, loadPhishTankFeed } from './parser.mjs';
import {
  createPhishTankProvider,
  unavailablePhishTankFinding,
} from './providers/phishtank.mjs';
import { fetchUrlhausRecentCsv } from './urlhaus/download.mjs';
import { buildUrlhausIndex, loadUrlhausFeed } from './urlhaus/parser.mjs';
import {
  createUrlhausProvider,
  unavailableUrlhausFinding,
} from './urlhaus/provider.mjs';
import {
  buildOpenPhishIndex,
  createOpenPhishProvider,
  fetchOpenPhishFeed,
  unavailableOpenPhishFinding,
} from './providers/openphish.mjs';

export const DEFAULT_FEED_PATH = fileURLToPath(
  new URL('./data/phishtank-snapshot-2026-07-16.json.gz', import.meta.url),
);
export const DEFAULT_URLHAUS_CACHE_PATH = fileURLToPath(
  new URL('./data/urlhaus-recent.csv', import.meta.url),
);
export const DEFAULT_OPENPHISH_CACHE_PATH = fileURLToPath(
  new URL('./data/openphish-feed.txt', import.meta.url),
);
const DEMO_PHISHTANK_PATH = new URL('./data/demo-phishtank.json', import.meta.url);
const DEMO_URLHAUS_PATH = fileURLToPath(
  new URL('./data/demo-urlhaus.csv', import.meta.url),
);
const DEMO_OPENPHISH_PATH = fileURLToPath(
  new URL('./data/demo-openphish.txt', import.meta.url),
);

export const SNAPSHOT_DATE = '2026-07-16';

function mergeIndexes(base, overlay) {
  if (!base) return overlay;
  if (!overlay) return base;

  return {
    exactUrls: new Map([...base.exactUrls, ...overlay.exactUrls]),
    hostnames: new Map([...base.hostnames, ...overlay.hostnames]),
    rawRecordCount: base.rawRecordCount + overlay.rawRecordCount,
    acceptedRecordCount: base.acceptedRecordCount + overlay.acceptedRecordCount,
    exactUrlCount: new Set([...base.exactUrls.keys(), ...overlay.exactUrls.keys()]).size,
    hostnameCount: new Set([...base.hostnames.keys(), ...overlay.hostnames.keys()]).size,
  };
}

async function loadDemoIndexes() {
  const [phishtankJson, urlhausCsv, openphishTxt] = await Promise.all([
    readFile(DEMO_PHISHTANK_PATH, 'utf8'),
    readFile(DEMO_URLHAUS_PATH, 'utf8'),
    readFile(DEMO_OPENPHISH_PATH, 'utf8').catch(() => ''),
  ]);

  return {
    phishtank: buildPhishTankIndex(JSON.parse(phishtankJson)),
    urlhaus: buildUrlhausIndex(urlhausCsv),
    openphish: buildOpenPhishIndex(openphishTxt),
  };
}

function emptyProviderState(provider) {
  return {
    available: false,
    index: null,
    provider,
    initializedAt: null,
    updatedAt: null,
    source: null,
    error: null,
  };
}

let state = {
  initializedAt: null,
  phishtank: emptyProviderState(createPhishTankProvider(null)),
  urlhaus: emptyProviderState(createUrlhausProvider(null)),
  openphish: emptyProviderState(createOpenPhishProvider(null)),
};

async function initializePhishTank(feedPath, initializedAt, records, demoIndex) {
  try {
    const baseIndex = records === undefined
      ? await loadPhishTankFeed(feedPath)
      : buildPhishTankIndex(records);
    const index = mergeIndexes(baseIndex, demoIndex);
    const baseSource = records === undefined ? 'bundled-snapshot' : 'configured-records';
    state.phishtank = {
      available: true,
      index,
      provider: createPhishTankProvider(index),
      initializedAt,
      updatedAt: initializedAt,
      source: demoIndex ? `${baseSource}+demo-fixtures` : baseSource,
      error: null,
    };
  } catch (error) {
    if (demoIndex) {
      state.phishtank = {
        available: true,
        index: demoIndex,
        provider: createPhishTankProvider(demoIndex),
        initializedAt,
        updatedAt: initializedAt,
        source: 'demo-fixtures-fallback',
        error: String(error?.message ?? error),
      };
      return;
    }
    if (!state.phishtank.available) {
      state.phishtank = {
        ...emptyProviderState(createPhishTankProvider(null)),
        initializedAt,
        error: String(error?.message ?? error),
      };
    } else {
      state.phishtank = { ...state.phishtank, error: String(error?.message ?? error) };
    }
  }
}

function validateUrlhausIndex(index) {
  if (!index || index.exactUrlCount < 1) {
    throw new Error('URLhaus feed contains no valid URL records');
  }
  return index;
}

async function writeTextCache(cachePath, text) {
  const temporaryPath = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
  try {
    await writeFile(temporaryPath, text, 'utf8');
    await rename(temporaryPath, cachePath);
  } finally {
    await unlink(temporaryPath).catch(() => undefined);
  }
}

async function writeUrlhausCache(cachePath, csv) {
  await writeTextCache(cachePath, csv);
}

async function loadUrlhausCache(cachePath) {
  return validateUrlhausIndex(await loadUrlhausFeed(cachePath));
}

function validateOpenPhishIndex(index) {
  if (!index || index.exactUrlCount < 1) {
    throw new Error('OpenPhish feed contains no valid URL records');
  }
  return index;
}

async function initializeUrlhaus(options, initializedAt, demoIndex) {
  const cachePath = options.urlhausFeedPath ?? DEFAULT_URLHAUS_CACHE_PATH;
  const authKey = options.urlhausAuthKey ?? process.env.URLHAUS_AUTH_KEY;

  try {
    let index;
    let source;
    let warning = null;
    if (authKey) {
      try {
        const csv = await fetchUrlhausRecentCsv(authKey, options.fetchImpl);
        index = validateUrlhausIndex(buildUrlhausIndex(csv));
        source = 'authenticated-download';
        if (!options.urlhausFeedPath) {
          try {
            await writeUrlhausCache(cachePath, csv);
          } catch {
            warning = 'URLhaus feed loaded, but the local cache could not be updated';
          }
        }
      } catch (downloadError) {
        try {
          index = await loadUrlhausCache(cachePath);
          source = 'local-cache-fallback';
          warning = String(downloadError?.message ?? downloadError);
        } catch {
          throw new Error('URLhaus download failed and no valid local cache is available');
       
[truncated — 6156 more characters]
```

### vitest.config.ts

```typescript
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['tests/**/*.test.ts'],
  },
});

```

### test-pages/external-form.html

```html
<!doctype html>
<!-- Harmless simulation: a login form posting to a different (dummy) host. Use fake credentials only. -->
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Employee Portal</title>
</head>
<body style="font-family: sans-serif; max-width: 380px; margin: 60px auto;">
  <h1>Employee Portal</h1>
  <p>Please authenticate to access internal resources.</p>
  <form action="http://127.0.0.1:9999/collect" method="post">
    <p><label>Username<br><input type="text" name="username"></label></p>
    <p><label>Password<br><input type="password" name="password"></label></p>
    <p><button type="submit">Continue</button></p>
  </form>
</body>
</html>

```

### test-pages/urgency-page.html

```html
<!doctype html>
<!-- Harmless simulation: social-engineering language with no forms. -->
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Important security notification</title>
</head>
<body style="font-family: sans-serif; max-width: 560px; margin: 60px auto;">
  <h1>⚠️ Final warning: your account will be suspended</h1>
  <p>Our security team detected unusual activity on your account. You must act
    immediately to avoid suspension. Verify your identity within 24 hours or your
    account will be permanently deleted.</p>
  <p>A refund is pending on your account, but your payment method was declined.
    Update your billing information right away to claim it.</p>
  <p>This is an official notice from the fraud department.</p>
</body>
</html>

```

### tests/helpers.ts

```typescript
import type { FormEvidence, PageEvidence } from '../extension/shared/types';

export function makeEvidence(partial: Partial<PageEvidence> = {}): PageEvidence {
  return {
    url: 'https://example.com/',
    title: '',
    visibleText: '',
    headings: [],
    imageAltText: [],
    metaDescription: '',
    faviconUrl: '',
    passwordFieldCount: 0,
    emailFieldCount: 0,
    forms: [],
    ...partial,
  };
}

export function makeForm(partial: Partial<FormEvidence> = {}): FormEvidence {
  return {
    action: 'https://example.com/session',
    method: 'post',
    hasPassword: false,
    sensitiveFields: [],
    hiddenSensitiveFields: [],
    pageDomain: 'example.com',
    actionDomain: 'example.com',
    crossDomain: false,
    secureSubmission: true,
    jsIntercepted: false,
    ...partial,
  };
}

```

[58 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]