# VIYUH BDR — Category 1 (Phishing) — Test Cases

Category 1 covers VIYUH BDR's browser-side phishing defences: real-time detection of credential-harvesting pages, deceptive domains, and malicious content the user actually sees. It comprises **10 features** (homograph, AiTM, SSO-clone, brand-impersonation, BITB, credential-reuse, password-strength, webmail-response, QR/quishing, phish-reputation). This file is the single source of truth for manual verification — run every case here before signing off a release.

## 0. Test Setup (do this once)

- **Load the extension.** Open `chrome://extensions`, enable Developer Mode, download `http://localhost:4004/ext/bdr-extension-unpacked.zip`, unzip it, and "Load unpacked" the folder. Use **v0.4.4+**. After any policy/bundle change, hit the reload icon on the extension card.
- **Where to watch results:**
  - the **page console** (DevTools on the page under test),
  - the **service-worker console** (`chrome://extensions` → BDR card → "service worker" link) — this is where detection/skip lines and OCR output print,
  - the **SOAR dashboard BDR detections view** (`http://localhost:3000` Incidents) — where dashboard incidents land.
- **Serving local fixtures over http.** Content scripts run on `http(s)` only, never `file://`. Serve fixtures from the **Test Range server at `http://localhost:4005`** (the per-feature fixtures referenced below assume this). For ad-hoc pages use `python3 -m http.server <port>` in the fixture directory. For look-alike-host cases, alias via `*.localhost` subdomains or add a line to `/etc/hosts`. Navigation-time detectors (homograph, phish-reputation nav path) fire before DNS, so the look-alike target need not resolve.
- **Legend:**
  - `[LIVE]` = real-time on a live site or your own mail/account; `[FIXTURE]` = local Test Range page.
  - **Enforcement types:** **BLOCK page** (hard full-page interstitial) / **WARN interstitial** (amber, "Proceed anyway") / **BANNER** (in-page nudge) / **NEUTRALIZE** (element blur, e.g. QR) / **NONE** (silent).
  - **Precision note:** every NEGATIVE case MUST stay completely silent — no console line, no incident. Negatives are the gate; a NEGATIVE that fires is a release-blocking false positive. Only test against your own pages/accounts — never point a test at a third-party victim.

---

## 1. Homograph / Typosquatting / Combosquatting

Flags deceptive hostnames (confusable/IDN look-alikes, deceptive subdomains, combosquats, and recognised typosquats) of protected brands at navigation time.

**Feature key:** `homograph` · subscribes to `navigation` · emits ONE scored signal (arbiter decides final enforcement). MITRE: T1566.002, T1656.

**Enforcement model (precision-first, ext v0.2.0):** a *lone* homograph hit is HIGH-severity but **confidence-tier 2**, so it raises a **proceed-able WARN interstitial** (amber, "Proceed anyway") — NOT a hard block — to avoid hard-blocking a possibly-legit look-alike. A hard **BLOCK page** only occurs when a content detector (sso-clone / aitm) co-fires or 2+ features agree. MEDIUM typosquats → **WARN interstitial**, no block. A `bdr:homograph` dashboard incident is created for every fire. NONE rows must stay completely silent (no console line, no incident).

**Live harness:** Chrome `chrome://extensions` → service-worker console + `http://localhost:3000` Incidents. Self-host positives over **http(s)** (Test Range `http://localhost:4005`, or your own page that links/redirects to the test hostname) — navigation fires the detector before DNS, so the target need not resolve. Use real production sites for negative/FP checks. Never point tests at third-party victims.

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| HG-P01 | POSITIVE — confusable/leetspeak (path 1) [LIVE] | Self-hosted page links to `http://micros0ft.com` (0→o); click it | HIGH, sev=high; WARN interstitial; incident `bdr:homograph` (kind `confusable`, brand microsoft) |
| HG-P02 | POSITIVE — confusable (path 1) [FIXTURE] | Range `/homograph.html` → `amaz0n.com` | HIGH; WARN interstitial; incident `bdr:homograph` (confusable → amazon) |
| HG-P03 | POSITIVE — confusable (path 1) [FIXTURE] | `paypa1.com` (1→l), `g00gle.com`, `faceb00k.com`, `netf1ix.com` | each HIGH; WARN interstitial; incident `bdr:homograph` (confusable) |
| HG-P04 | POSITIVE — new-brand confusable (path 1) [FIXTURE] | `disc0rd.com` | HIGH; WARN interstitial; incident `bdr:homograph` (confusable → discord) |
| HG-P05 | POSITIVE — IDN homograph (path 1, puny) [LIVE] | Self-hosted page links to `http://xn--pypal-4ve.com` (Cyrillic "а" paypal); click | HIGH, sev=high; WARN interstitial; incident `bdr:homograph` (kind `idn-homograph`, brand paypal); evidence shows decoded host |
| HG-P06 | POSITIVE — deceptive subdomain (path 2) [LIVE] | Self-host links to `http://paypal.com.secure-login.xyz/`; click | HIGH; WARN interstitial; incident `bdr:homograph` (kind `deceptive-subdomain`, brand paypal; real registrable = secure-login.xyz) |
| HG-P07 | POSITIVE — deceptive subdomain (path 2) [FIXTURE] | `accounts.apple.com.verify.tld`, `login.microsoft.com.evil.io` | HIGH; WARN interstitial; incident `bdr:homograph` (deceptive-subdomain → apple / microsoft) |
| HG-P08 | POSITIVE — combosquat token (path 3a) [LIVE] | Self-host links to `http://paypal-secure.com`; click | HIGH; WARN interstitial; incident `bdr:homograph` (kind `combosquat`, brand paypal; tokens paypal,secure) |
| HG-P09 | POSITIVE — combosquat token (path 3a) [FIXTURE] | `login-microsoft.io`, `amazon-support.com` | HIGH; WARN interstitial; incident `bdr:homograph` (combosquat) |
| HG-P10 | POSITIVE — combosquat glued (path 3b) [FIXTURE] | `securepaypal.net`, `paypalsecure.com` (brand + lure, no separator) | HIGH; WARN interstitial; incident `bdr:homograph` (combosquat, lure word) |
| HG-P11 | POSITIVE — new-brand combosquat (path 3) [FIXTURE] | `stripe-secure.com`, `fedex-delivery.com`?, `venmo-login.com`, `kraken-wallet.com`, `salesforce-verify.com` | HIGH; WARN interstitial; incident `bdr:homograph` (combosquat → stripe/venmo/kraken/salesforce). Note: `fedex-delivery` — "delivery" is NOT a lure word, fires only as token if separated; "delivery" not in LURE_WORDS so glued form would NOT fire (see EDGE HG-E07) |
| HG-P12 | POSITIVE — mixed-script near brand (path 4) [LIVE] | Self-host links to a punycode host whose decoded label is 1 edit from a brand skeleton (e.g. `xn--…` decoding to "paypal"/"gooogle"-class) | HIGH; WARN interstitial; incident `bdr:homograph` (kind `idn-homograph`/`mixed-script`) |
| HG-P13 | POSITIVE — typosquat doubled letter (path 5) [FIXTURE] | `gooogle.com` (duplication), `paypall.com` | MEDIUM, sev=medium; WARN interstitial (no hard block); incident `bdr:homograph` (kind `typosquat`) |
| HG-P14 | POSITIVE — typosquat duplication (path 5) [FIXTURE] | `githubb.com` (github ≥5 chars, doubled b) | MEDIUM; WARN interstitial; incident `bdr:homograph` (typosquat: duplication) |
| HG-P15 | POSITIVE — typosquat transposition/adjacent (path 5) [LIVE] | Self-host links to `http://googel.com` (transposition) or `amazom.com` (n→m adjacent key); click | MEDIUM; WARN interstitial; incident `bdr:homograph` (typosquat: transposition / adjacent-key) |
| HG-P16 | POSITIVE — low-conf bare punycode (path 4 fallback) [LIVE] | Self-host links to a `xn--` host that decodes to non-brand unicode (no brand match) | LOW, sev=low; no interstitial/banner (informational); incident `bdr:homograph` (kind `idn`) — verify it does NOT block |
| HG-N01 | NEGATIVE — real brand canonical [LIVE] | Browse to real `https://google.com` | Silent. REAL_DOMAINS guard → no signal, no incident, console quiet |
| HG-N02 | NEGATIVE — real IdP login host [LIVE] | Browse to `https://accounts.google.com`, `https://login.microsoftonline.com`, `https://outlook.office.com` | Silent — registrable is a real/alias domain; no incident |
| HG-N03 | NEGATIVE — real multi-part-TLD alias [LIVE] | Browse to `https://google.co.uk` (and `amazon.co.uk`, `amazon.in`) | Silent — alias in REAL_DOMAINS, registrable() resolves eTLD+1 correctly; no incident |
| HG-N04 | NEGATIVE — real alias domains [LIVE] | Browse to `https://icloud.com`, `https://live.com`, `https://office.com`, `https://protonmail.com` | Silent — brand aliases are legitimate; no incident |
| HG-N05 | FALSE-POSITIVE guard — brand as substring of real word [LIVE] | Browse to `https://appleinsider.com` and `https://pineapple.com` | Silent — "apple" is a mere substring, not a token and rest is not a lure word; no incident |
| HG-N06 | FALSE-POSITIVE guard — brand + non-lure suffix [LIVE] | Browse to `https://amazonaws.com`, `https://microsoftteams.com` | Silent — "aws"/"teams" not in LURE_WORDS, single label so no token split; no incident |
| HG-N07 | FALSE-POSITIVE guard — real words 1 edit from a brand [LIVE] | Browse to `https://apply.com` (~apple), `wahoo.com` (~yahoo), `adore.com` (~adobe), `okra.com`?(~okta), `chose.com` (~chase), `email.com` (~gmail) | Silent — typoSquatKind gates to recognised typo classes on ≥5-char brands; arbitrary 1-edit substitutions excluded; no incident |
| HG-N08 | NEGATIVE — short-brand arbitrary edit [FIXTURE] | `okra.com` vs okta (4-char brand), `citi`/`amex`-class short names with a single random substitution | Silent — brands <5 chars are not typosquat-gated; no incident |
| HG-N09 | NEGATIVE — benign unrelated domain [LIVE] | Browse to `https://example.com`, your own `http://localhost:4005/negative.html` | Silent — no brand resemblance; no incident |
| HG-N10 | NEGATIVE — non-doubling insertion ≠ typo [FIXTURE] | `metalmask.com` (metamask + inserted non-doubling char), `microsofty.com` non-adjacent edit | Silent — duplication path requires a doubled neighbour char; no incident |
| HG-E01 | EDGE — www / trailing-dot normalization [LIVE] | Browse to `http://www.micros0ft.com.` (leading www, trailing dot) | HIGH same as HG-P01 — normalization strips www/dot before analysis; incident `bdr:homograph` |
| HG-E02 | EDGE — uppercase / mixed case [LIVE] | Self-host links to `http://PayPal-SECURE.com` | HIGH — host lowercased before analysis; incident `bdr:homograph` (combosquat) |
| HG-E03 | EDGE — real brand inside deceptive subdomain still wins guard? [LIVE] | Browse to legit deep subdomain `https://login.live.com` | Silent — registrable live.com is a real alias; the subdomain scan must NOT mis-fire on a legitimate brand-owned host |
| HG-E04 | EDGE — IP literal [FIXTURE] | Navigate to `http://93.184.216.34/` | Silent — registrable() returns IP verbatim, no brand match; no incident |
| HG-E05 | EDGE — typosquat brand-length boundary [FIXTURE] | A ≥5-char brand 1-edit typo fires MEDIUM; the same edit on a <5-char brand stays silent | Confirms the 5-char gate; only the ≥5 case creates `bdr:homograph` |
| HG-E06 | COMBO — homograph + content kit ⇒ escalate to BLOCK [LIVE] | Self-host an AiTM/sso-clone fixture page served from a look-alike host (e.g. `http://paypa1.com`-style host you control) | ONE incident only (not two); arbiter raises to hard **BLOCK page**; headline = highest severity; homograph listed under `contributing_features`/evidence |
| HG-E07 | COMBO/EDGE — combosquat with non-lure word [FIXTURE] | `fedex-delivery.com` glued form `fedexdelivery.com` | Glued form silent ("delivery" not a lure word); separated `fedex-delivery.com` fires via token path (brand is its own token). Documents the token-vs-glued asymmetry |
| HG-E08 | COMBO — 2+ homograph-class hosts / multi-signal page [LIVE] | A page you control that auto-redirects through a deceptive-subdomain host then a combosquat host | Each navigation independently scored; arbiter dedupes to ONE `bdr:homograph` incident per distinct deceptive host; no double-counting |

---

## 2. AiTM / Reverse-Proxy

Catches Evilginx/Tycoon-style credential relay: a password form that posts off-domain to a real identity provider, or a page carrying a known reverse-proxy kit fingerprint.

**Feature key:** `aitm` (`bdr:aitm`). Two detection paths — **Signal 1** (kit fingerprints, score 92) and **Signal 2** (cross-origin credential POST to an IdP, score 90 if look-alike host, 78 otherwise). Key guards: requires `hasPassword` AND `crossOriginPost`; the IdP target must have a different registrable domain than the page (`registrable(t) !== pageReg`); same-origin posts are allowed; pages with no password field are ignored. MITRE T1557, T1566.002.

| ID | Type | Scenario / how to trigger | Expected result |
|---|---|---|---|
| AITM-P01 | POSITIVE — Signal 1 (kit) [FIXTURE] | Serve your own `aitm-kit.html?kit=evilginx` over `http://evilginx-kit.localhost:4005/...` — injects `<meta name="evilginx">`. | **HIGH** (score 92). **BLOCK page**. Incident `bdr:aitm`, kind `aitm`, headline "Reverse-proxy phishing kit detected (evilginx)". MITRE T1557/T1566.002. |
| AITM-P02 | POSITIVE — Signal 1 (kit) [FIXTURE] | Same fixture `?kit=modlishka` — injects `script[src*="modlishka"]`. | **HIGH** (92). **BLOCK page**. Incident `bdr:aitm`, kit=modlishka. |
| AITM-P03 | POSITIVE — Signal 1 (kit) [FIXTURE] | `?kit=muraena` — `script[src*="muraena"]`. | **HIGH** (92). **BLOCK page**. Incident `bdr:aitm`, kit=muraena. |
| AITM-P04 | POSITIVE — Signal 1 (kit, generic) [FIXTURE] | `?kit=generic-proxy` — injects hidden `input[name="rid"][type="hidden"]` (session-relay token). | **HIGH** (92). **BLOCK page**. Incident `bdr:aitm`, kit=generic-proxy. Proves kit path fires with NO brand text required. |
| AITM-P05 | POSITIVE — Signal 1 + look-alike [LIVE] | Build a one-page login, host it on your own domain `micros0ft-login.<your-test-domain>` over https, drop the `<meta name="evilginx">` marker in. Open it yourself. | **HIGH** (92). **BLOCK page**. Evidence adds "domain look-alike of microsoft". Single incident `bdr:aitm` (arbiter dedups with homograph). |
| AITM-P06 | POSITIVE — Signal 2 (relay) [FIXTURE] | `aitm-xorigin.html?idp=microsoft` on a non-MS host — password form `action=https://login.microsoftonline.com/...`, cross-origin POST. | **HIGH** (score 78, no look-alike). **BLOCK page**. Incident `bdr:aitm`, targets=[login.microsoftonline.com], headline "Credentials posted off-domain to login.microsoftonline.com". |
| AITM-P07 | POSITIVE — Signal 2 (relay) [FIXTURE] | `?idp=google` → POST to `accounts.google.com`. | **HIGH** (78). **BLOCK page**. Incident `bdr:aitm`, targets=[accounts.google.com]. |
| AITM-P08 | POSITIVE — Signal 2 (relay) [FIXTURE] | `?idp=okta` → `login.okta.com`; repeat `?idp=paypal` → `paypal.com`, `?idp=amazon` → `signin.aws.amazon.com`. | **HIGH** (78) each. **BLOCK page**. Incident `bdr:aitm` with the respective IdP target. Proves IdP-set coverage. |
| AITM-P09 | POSITIVE — Signal 2 + look-alike escalation [LIVE] | Host `aitm-xorigin.html?idp=microsoft` on your own look-alike domain `0ffice-login.<your-test-domain>` (https). Open it yourself. | **HIGH** (score **90**, not 78 — look-alike bumps it). **BLOCK page**. Evidence adds "domain also look-alike of microsoft". Single incident `bdr:aitm`. |
| AITM-P10 | POSITIVE — Signal 2 (live self-built) [LIVE] | Compose your own static page: email field + `<input type=password>` + `<form action="https://accounts.google.com/...">`, serve over `http://localhost:4005`. Submit-attempt yourself. | **HIGH** (78). **BLOCK page**. Incident `bdr:aitm`. Confirms detection on hand-built content, not just shipped fixtures. |
| AITM-N01 | NEGATIVE (FP guard — REAL site) [LIVE] | Visit real `https://login.microsoftonline.com` and sign into your own MS/365 account. Page domain == IdP registrable → `registrable(t) === pageReg` filter drops it. | **NONE**. No block, no banner, no incident. Real IdP posting to itself must stay silent. |
| AITM-N02 | NEGATIVE (FP guard — REAL site) [LIVE] | Visit real `https://accounts.google.com` and sign in to your own Google account. | **NONE**. No detection. Same-registrable IdP self-post is excluded. |
| AITM-N03 | NEGATIVE (FP guard — REAL site) [LIVE] | Sign into your own account at real `https://github.com/login` and `https://okta.com` / your-tenant `*.okta.com`. | **NONE**. No detection. First-party IdP login is legitimate. |
| AITM-N04 | NEGATIVE (FP guard — REAL federated SSO) [LIVE] | On a real SaaS app (e.g. your own `https://*.atlassian.net` or `https://*.slack.com`) click "Sign in with Google/Microsoft" — the redirect lands you ON `accounts.google.com`/`login.microsoftonline.com` to enter the password. | **NONE**. The password is entered on the IdP's own domain (same registrable), so Signal 2 does not fire. This is the most important real-world FP class. |
| AITM-N05 | NEGATIVE (FP guard — same-origin) [FIXTURE] | `negative.html?case=generic` — generic corporate login whose form posts SAME-ORIGIN (no `crossOriginPost`). | **NONE**. `login.crossOriginPost` is false → early return. No detection. |
| AITM-N06 | NEGATIVE (FP guard — no password) [FIXTURE] | Page that mentions a brand / shows a SSO button but has NO `<input type=password>` (`login.hasPassword` false). | **NONE**. Early return on `!hasPassword`. Marketing/landing pages stay silent. |
| AITM-N07 | NEGATIVE (FP guard — non-IdP cross-origin) [LIVE] | A page that posts a password cross-origin to a NON-IdP endpoint (e.g. your own `api.<your-domain>` analytics/auth backend not in `IDP_DOMAINS`). | **NONE**. `idpTargets` filters to empty (target not an IdP) → no detection. Normal cross-origin auth backends are allowed. |
| AITM-N08 | NEGATIVE (FP guard — REAL site) [LIVE] | Log into a major real site that posts to its own auth host with no kit markers (e.g. `https://www.paypal.com` → `paypal.com`, `https://amazon.com` sign-in). | **NONE**. Page registrable == target registrable; no off-domain relay. No incident. |
| AITM-E01 | EDGE — cross-origin to non-IdP subdomain of self [LIVE] | Page on `app.<your-domain>` posts password to `auth.<your-domain>` (different host, SAME registrable). | **NONE**. `registrable(t) !== pageReg` is false → target excluded. Sibling-subdomain auth is not AiTM. |
| AITM-E02 | EDGE — multiple IdP targets [FIXTURE] | Self-built page whose form lists 2+ cross-origin IdP actions (e.g. microsoftonline + okta). | **HIGH** (78, or 90 if look-alike). One incident; `targets` array carries all matched IdPs; headline names `idpTargets[0]`. |
| AITM-E03 | COMBO — kit + relay on one page [LIVE] | Self-host a page with BOTH the `evilginx` meta marker AND a cross-origin POST to `login.microsoftonline.com`. | **HIGH**. **Signal 1 wins** (kit path returns first, score 92, kit evidence). Exactly ONE `bdr:aitm` incident, not two. |
| AITM-E04 | COMBO — AiTM + brand-impersonation / homograph [LIVE] | Self-host the kit/relay page on a look-alike domain that also trips homograph + brand-impersonation. | **HIGH**, **single** incident. Arbiter dedups: highest-severity headline shown, other features under `contributing_features` / evidence. Verify only ONE dashboard incident appears. |
| AITM-E05 | EDGE — dedup window (shared host) [LIVE] | Off-`localhost`, open two positive cases back-to-back on the same host. | Second may be **suppressed** for ~60s by the arbiter dedup key. Expected: re-open after ~60s, or use distinct `*.localhost` subdomains so each fires independently (not a miss — by design). |

---

## 3. SSO-Clone (AiTM anti-evasion)

Visible-or-OCR'd brand login content on a non-brand domain → credential-harvest clone / reverse-proxy AiTM.

Confidence-tier 5 + `high` severity → every true SSO-clone hit hard-**blocks** (BDR block page) and raises a `bdr:sso-clone` incident (`category: phishing`, `kind: sso-clone`, MITRE T1557 + T1566.002). Detection requires ALL of: a login indicator present, the brand's `strong` word visible, ≥2 signature phrases, and ≥1 of them brand-distinctive (outside the universal-login set) — on a domain that is NOT the brand's real/alias domain. Layer 1 reads visible DOM text; Layer 2 OCRs the captured login image when the page is `imageHeavy` with sparse text (60s/host throttle). Self-host every positive fixture over `http://localhost:4005` (or your own host) — never point these at a third party.

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| SSO-P1 | POSITIVE [FIXTURE] | Layer-1 visible text. Serve `aitm-ms-clone.html` (Microsoft "Sign in" + "No account? Create one!" + "Can't access your account?" + "Keep me signed in") on `localhost:4005` (non-microsoft domain). | HIGH; **BLOCK page**; incident `bdr:sso-clone` (brand=microsoft, source=visible-text). Evidence lists matched phrases + "page domain … (not microsoft)". |
| SSO-P2 | POSITIVE [FIXTURE] | Layer-1 visible text, second brand. Serve `aitm-google-clone.html` ("Google" + "Sign in" + "Use your Google Account" + "Forgot email?" + "Create account") on `localhost:4005`. | HIGH; **BLOCK page**; incident `bdr:sso-clone` (brand=google, source=visible-text). Confirms the matcher is brand-generic, not Microsoft-only. |
| SSO-P3 | POSITIVE [FIXTURE] | Layer-2 OCR anti-evasion. Serve `aitm-ms-image.html` — the whole MS login is a raster PNG (`aitm-ms-login.png`), DOM text is near-empty, page is `imageHeavy`. Open over **http** on `localhost:4005`. | HIGH; **BLOCK page**; incident `bdr:sso-clone` (brand=microsoft, **source=ocr**). SW log shows the OCR'd chars. Proves detection from rendered pixels, not DOM/title. |
| SSO-P4 | POSITIVE [LIVE] | Build-your-own clone. In an editor, save a copy of the real `login.microsoftonline.com` sign-in page (or hand-write the MS text) to `~/sso-test/index.html`, serve via `python3 -m http.server 4005`, open `http://localhost:4005`. | HIGH; **BLOCK page**; incident `bdr:sso-clone` (brand=microsoft). Real-time proof on self-hosted content, not the canned fixture. |
| SSO-P5 | POSITIVE [LIVE] | Email-to-self delivery path. Compose a Gmail to yourself, paste the MS-clone HTML body / attach the fixture, open it from your own inbox-served link on `localhost`. | HIGH; **BLOCK page**; `bdr:sso-clone` raised. Mirrors how a clone actually arrives (self-sent, never a third party). |
| SSO-P6 | POSITIVE [LIVE] | OCR live, second brand. Render your Google-login screenshot PNG inside a sparse-DOM `imageHeavy` page on `localhost:4005`; open over http. | HIGH; **BLOCK page**; `bdr:sso-clone` (brand=google, source=ocr). |
| SSO-N1 | NEGATIVE / FP [LIVE] | Real Microsoft IdP. Visit `https://login.microsoftonline.com` and sign in. | **No detection** — `isRealBrandDomain` skips microsoft. SW console stays quiet, no incident. (Top FP guard.) |
| SSO-N2 | NEGATIVE / FP [LIVE] | Real Google. Visit `https://accounts.google.com`. | **No detection** — real google domain skipped. No incident. |
| SSO-N3 | NEGATIVE / FP [LIVE] | Real Outlook / Office. Visit `https://outlook.office.com` / `office.com` sign-in. | **No detection** — microsoft alias domain skipped. No incident. |
| SSO-N4 | NEGATIVE / FP [LIVE] | Real Apple / PayPal / Amazon / LinkedIn / Okta / Facebook logins on their own domains. | **No detection** for any — each is the brand's real/alias domain. No incident. |
| SSO-N5 | NEGATIVE / FP [FIXTURE] | Generic non-brand login. Serve `aitm-negative.html` ("Acme Intranet", username+password, **no brand SSO signature**) on `localhost:4005`. | **No detection** — no `strong` brand word → silent. No incident. (Precision guard.) |
| SSO-N6 | NEGATIVE / FP [LIVE] | Federated SSO button on a legit third party. Visit `https://github.com/login` (offers "Sign in with Google/Apple" style buttons) or any SaaS login showing "Continue with Google". | **No detection** — `FEDERATED_BTN` strips "sign in with Google/Apple", so the brand word is removed before matching. No incident. |
| SSO-N7 | NEGATIVE / FP [LIVE] | Brand merely named, no login. Open a page that mentions "LinkedIn"/"Apple" in a footer link with only universal words ("sign in", "password") but no brand-distinctive phrase. | **No detection** — `wordHits` has no non-universal phrase → universal-word gate blocks it. No incident. |
| SSO-N8 | NEGATIVE / FP [LIVE] | Non-login brand page. Open a Microsoft/Google marketing or docs page (brand word present, no login indicator). | **No detection** — `LOGIN_INDICATORS` check fails first → returns null. No incident. |
| SSO-N9 | NEGATIVE / FP [FIXTURE] | One signature phrase only. Edit a copy of the MS fixture down to "Microsoft" + "Sign in" and nothing else distinctive; serve on `localhost:4005`. | **No detection** — `wordHits < 2`. Confirms the ≥2-phrase threshold. No incident. |
| SSO-N10 | NEGATIVE / FP [LIVE] | OCR path not armed. Open a normal text-rich login (sparse images, not `imageHeavy`) on a non-brand domain with no brand text. | **No detection**, **no OCR run** (Layer-2 gate `imageHeavy && imageData` not met). No wasted screenshot, no incident. |
| SSO-E1 | EDGE [FIXTURE] | OCR still decoding. Open `aitm-ms-image.html` before the PNG finishes loading (first scan). | **No detection yet, throttle NOT armed** — next scan (on image load) gets a clean shot, then HIGH block fires. Verifies skip-without-arming behaviour. |
| SSO-E2 | EDGE [FIXTURE] | OCR throttle. Reload the same `imageHeavy` clone host twice within 60s. | First load: HIGH block + incident. Repeat within 60s: OCR path throttled (no duplicate heavy OCR); arbiter dedup keeps it to ONE incident. |
| SSO-E3 | EDGE [LIVE] | `www.` / subdomain on a clone. Host the MS clone at `www.<your-host>` or `login.<your-host>`. | HIGH; **BLOCK page**; `bdr:sso-clone`. `www.` is stripped and `registrable()` reduces to eTLD+1 before the real-domain check, so the clone still fires. |
| SSO-C1 | COMBO [LIVE] | Clone on a look-alike domain. Host the MS visible-text clone on a homograph host (e.g. `m1crosoft-login.<tld>` via local DNS/hosts). homograph (conf 2) + sso-clone (conf 5) both signal. | **ONE** incident; headline = sso-clone (HIGH, highest confidence); homograph attached under `contributing_features`/evidence. **BLOCK page** (corroborated + confident). |
| SSO-C2 | COMBO [LIVE] | Clone behind a reverse-proxy AiTM kit (evilginx-style markers) on your own test host. aitm + sso-clone co-fire. | **ONE** incident, HIGH, **BLOCK page**; both features listed, sso-clone owns the headline. No double-block. |
| SSO-A1 | ALLOWLIST [LIVE] | Admin allowlists your internal SSO portal that legitimately mirrors brand text. Add the domain to the org allowlist, then open it. | **No detection, fully suppressed** at arbiter (`isAllowlisted`) before any verdict — zero noise, no block, no incident. |
| SSO-A2 | POLICY [LIVE] | Admin disables the feature. Toggle `sso-clone` off in policy, open `aitm-ms-clone.html` on `localhost:4005`. | **No detection** — `policy.isEnabled("sso-clone")` short-circuits the handler. Re-enable → SSO-P1 fires again. |

---

## 4. Brand-Impersonation

A brand's login form served on a domain that is NOT that brand's real domain.

Detection model (from `brand-impersonation.js` + `arbiter.js`): fires only when the page has a **password field** AND presents a brand identity, via one of two paths — (a) **look-alike domain** (`matchBrandLookalike`: confusable/IDN-homograph/typosquat) → `severity:high, score:86`; or (b) **page branding** (brand name in `<title>` / `og:site_name`, federated "… with &lt;brand&gt;" phrasing stripped first) → `severity:medium, score:68`. Confidence tier = 3. Enforcement (precision-first): **high + confident (conf 3 ≥ 3, or 2+ features corroborate)** → hard **BLOCK** interstitial; **medium** → in-page **BANNER** nudge (no block); allowlisted domain → fully suppressed. Every fire creates a dashboard incident under feature key `brand-impersonation` (`bdr.category:"phishing"`). On a real brand domain or a passwordless brand page → silent.

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| BI-P1 | POSITIVE / branding path [FIXTURE] | Serve `impersonate.html?brand=microsoft` over **http** on `http://msportal.localhost:4005`. Brand is in `<title>` + logo `aria-label` only (not body text, not an SSO phrase). | DETECT, **medium**, `score 68`. Enforcement = **BANNER** (in-page nudge, no block). Incident `brand-impersonation` (brand=microsoft, heuristic:true). Headline "microsoft login page on non-microsoft domain". |
| BI-P2 | POSITIVE / branding path [FIXTURE] | Same fixture, iterate `brand=` over `google, apple, paypal, okta, amazon, github, dropbox` on their `*.localhost` subdomains. | Each → DETECT **medium**, **BANNER**, one incident per brand. No block. |
| BI-P3 | POSITIVE / look-alike domain (confusable) [LIVE] | Self-host the login fixture and open it via a confusable host that skeletonizes to a brand, e.g. add `127.0.0.1 paypa1.com` to `/etc/hosts` (or use a `*.localhost` alias `g00gle.localhost`) and load over http with a password form present. | DETECT, **high**, `score 86`. `matchBrandLookalike` → kind confusable. Enforcement = **BLOCK** (conf 3 ≥ 3). Block interstitial shown. Incident `brand-impersonation` high. |
| BI-P4 | POSITIVE / look-alike (IDN-homograph) [LIVE] | Self-host fixture on a punycode host decoding to a brand (e.g. `xn--pypal-4ve.com` → paypal) mapped to localhost; ensure password field. | DETECT, **high**, kind idn-homograph, **BLOCK** interstitial, high incident. |
| BI-P5 | POSITIVE / look-alike (typosquat) [LIVE] | Self-host fixture on a typo host of a ≥5-char brand (omission/dup/transposition/adjacent-key), e.g. `paypl.localhost`, `googel.localhost`, `dropbpx.localhost`. Password form present. | DETECT, **high**, kind typosquat, **BLOCK**, high incident. |
| BI-P6 | POSITIVE / your own email-to-self [LIVE] | Compose a mail to yourself, paste a link to your self-hosted look-alike login fixture (BI-P3 host), open it from the message. | On open → DETECT **high**, **BLOCK** interstitial before credential entry. Incident created. (No third party involved.) |
| BI-N1 | NEGATIVE / real brand domain [LIVE] | Visit the genuine login pages: `accounts.google.com`, `login.microsoftonline.com` / `outlook.office.com`, `github.com/login`, `paypal.com`, `appleid.apple.com`, `dropbox.com/login`. Enter nothing. | **No detection.** `isRealBrandDomain` / `REAL_DOMAINS` short-circuit. SW console silent, no incident. |
| BI-N2 | NEGATIVE / brand aliases [LIVE] | Visit alias domains in the data lake: `live.com`, `office.com`, `icloud.com`, `amazon.co.uk`, `google.co.in`, `microsoftonline.com`. | **No detection** (aliases are real domains for the brand). |
| BI-N3 | NEGATIVE / federated SSO button [LIVE] | Visit a legit app that shows "Sign in with Google" / "Continue with Apple" buttons (e.g. `gitlab.com/users/sign_in`, `figma.com`, a Vercel/Notion login). Brand named only in the SSO phrase. | **No detection.** `FEDERATED_RE` strips "… with &lt;brand&gt;" before matching; the page's own identity ≠ a named brand. No incident. |
| BI-N4 | NEGATIVE / no password field [FIXTURE+LIVE] | Self-host a page that names a brand in `<title>` but has **no** `type=password` input (e.g. a brand marketing/landing page, or a brand-named blog). | **No detection** — `evaluate` returns null when `!login.hasPassword`. No incident. |
| BI-N5 | NEGATIVE / generic login, no brand cue [FIXTURE] | Load `impersonate.html?brand=none` on `genportal.localhost` ("Company Portal", no brand in title/og, no look-alike). | **No detection** (negative control). SW console stays quiet. |
| BI-N6 | FALSE-POSITIVE guard / real-word host [LIVE] | Self-host the login fixture on real-word hosts that are one edit from a brand but NOT a recognised typo class: `apply.localhost` (≠apple), `chose.localhost` (≠chase), `adore.localhost` (≠adobe), `email.localhost` (≠gmail). | **No detection.** `typoSquatKind` rejects non-typo-class single-edits; short/real-word neighbours excluded. No incident. |
| BI-N7 | FALSE-POSITIVE guard / brand named in body only [FIXTURE] | Self-host a login page where the brand appears only in visible **body text** (e.g. "powered by Okta"), not in `<title>`/`og:site_name`, on a non-look-alike host. | **No detection** — `brandFromText` reads only title + og:site_name (logo/body deliberately excluded to avoid SSO/partner-badge FPs). No incident. |
| BI-E1 | EDGE / allowlist suppression [LIVE] | Take a page that WOULD fire (BI-P1 medium or BI-P3 high). Add its registrable domain to the org policy `allowlist` (`example-portal.localhost` or `*.example-portal.localhost`), reload bundle, re-open. | **No detection, no banner, no block.** `policy.isAllowlisted` suppresses the signal before evaluation. SW logs "allowlisted — suppressed". No incident. |
| BI-E2 | EDGE / feature disabled by policy [LIVE] | Disable `brand-impersonation` in the policy bundle; load BI-P1 fixture. | **No detection** — feature gated off; other features still run. |
| BI-C1 | COMBO / look-alike + homograph → ONE incident [LIVE] | Self-host the brand login fixture on a confusable host (BI-P3) so both `homograph` (conf 2) and `brand-impersonation` (conf 3) fire on the same nav. | **ONE** incident, **high**, **BLOCK**. Headline owned by the higher-confidence feature; the other listed under `contributing_features`/evidence. Corroborated (2+ features) → block even independent of confidence floor. No duplicate incident. |
| BI-C2 | COMBO / look-alike + sso-clone (content match) [LIVE] | Self-host a pixel-clone of a brand login (triggers `sso-clone`, conf 5) on a look-alike host (also triggers brand-impersonation). | **ONE** incident, **high**, **BLOCK**. Headline attributed to `sso-clone` (most specific, conf 5); brand-impersonation under evidence. Single dashboard incident. |
| BI-E3 | EDGE / medium never hard-blocks [FIXTURE] | Re-confirm BI-P1 (text-brand on plain non-look-alike host). | Enforcement stays **BANNER** — a medium-severity heuristic must never produce a full-page block (precision-first: a possible FP is at most a dismissible in-page nudge). Incident still recorded. |

---

## 5. Browser-in-the-Browser (BITB)

Catches a fake in-page "browser window" (title bar + traffic-lights + spoofed address bar) wrapping a login form, where the spoofed address bar names a real brand.

> Feature key `bitb` · subscribes to `dom` facts · score 84 / `confidence 4` · MITRE T1566.002, T1656. A lone bitb signal is **confident** (4 ≥ block-min 3) → **hard BLOCK page** + dashboard incident `bdr:bitb`. Two sensor paths feed it: **(1) named-chrome** (`collectBitb` path 1 — elements whose class/data hint at window chrome) and **(2) structural** (`collectBitb` path 2 — a leaf element rendering a foreign full-URL/`host/path` address bar within ~5 ancestors of a `password` field, no class hints). Both require `nearLogin` (a password field) and a spoof host whose registrable domain differs from the page's. The feature only fires when `fakeUrlText` contains a known brand key. (Brand match = `BRAND_KEYS.find(k => spoofText.includes(k))` — e.g. `login.microsoftonline.com` contains "microsoft"; `accounts.google.com` contains "google"; a foreign URL with no brand substring like `secure-portal.xyz` never fires.)

| ID | Type | Scenario / how to trigger | Expected result |
|---|---|---|---|
| BITB-P1 | POSITIVE — named-chrome path, brand [FIXTURE] | Serve `bitb-tests.html` over http on a non-brand host (e.g. `http://gwin.localhost:4005/...?brand=google`). Fake window (class hints `titlebar`/`urlbar`) with address bar `https://accounts.google.com` around a login form. | **HIGH**, **BLOCK page**. Incident `bdr:bitb`, `spoofed_brand: google`. Headline "Fake browser window spoofing google". |
| BITB-P2 | POSITIVE — named-chrome, microsoft alias [FIXTURE] | Same fixture, `?brand=microsoft` → address bar `https://login.microsoftonline.com/...`. Confirms alias text "microsoftonline" still contains key `microsoft`. | **HIGH**, **BLOCK page**, incident `bdr:bitb`, `spoofed_brand: microsoft`. |
| BITB-P3 | POSITIVE — named-chrome, apple/paypal/amazon [FIXTURE] | Fixture `?brand=apple` (`appleid.apple.com`), `paypal` (`paypal.com`), `amazon` (`signin.amazon.com`). | Each: **HIGH**, **BLOCK page**, incident `bdr:bitb` with matching `spoofed_brand`. |
| BITB-P4 | POSITIVE — STRUCTURAL path, no class hints [FIXTURE] | Serve `bitb-structural.html` over http on a non-brand host. Generic class names; only structural tells: traffic-light dots, leaf `<span>` showing `https://login.microsoftonline.com/common/oauth2/authorize`, password form nearby. Proves path 2 fires with zero naming hints (real BITB kits don't self-label). | **HIGH**, **BLOCK page**, incident `bdr:bitb`, `spoofed_brand: microsoft`. |
| BITB-P5 | POSITIVE — self-built live page [LIVE] | Build your OWN page: a `<div>` with three colored dots, a leaf element containing `https://accounts.google.com/signin`, and an `<input type="password">` within 5 ancestors. Serve it over `http(s)` (e.g. local `python3 -m http.server` on a non-google host) and open it. | **HIGH**, **BLOCK page**, incident `bdr:bitb`, `spoofed_brand: google`. Confirms live structural detection on hand-authored content. |
| BITB-P6 | POSITIVE — address-bar as `host/path` form [LIVE] | Self-built page where the fake bar text is `paypal.com/signin` (no scheme) near a password field — exercises the second `spoofIn` regex (`host/path`). | **HIGH**, **BLOCK page**, incident `bdr:bitb`, `spoofed_brand: paypal`. |
| BITB-N1 | NEGATIVE — real brand's OWN page [LIVE] | Visit `https://accounts.google.com` and sign-in flow; `https://login.microsoftonline.com`; `https://appleid.apple.com`. Page registrable domain ∈ `REAL_DOMAINS` → guard returns null even though brand URL text is present near a login. | **No detection.** SW console quiet, no incident. (Primary FP guard: a brand is never BITB on its own domain.) |
| BITB-N2 | NEGATIVE — real subdomain of brand [LIVE] | `https://mail.google.com`, `https://outlook.office.com`, `https://signin.aws.amazon.com`. Registrable domain matches a real brand/alias. | **No detection.** No incident. |
| BITB-N3 | NEGATIVE — foreign URL, NO brand [FIXTURE] | `bitb-tests.html` `?brand=none` (`nowin`): fake window with a non-brand address bar (e.g. `secure-portal.xyz`). `BRAND_KEYS.find` returns nothing. | **No detection** (precision fix v0.2.2). No block, no incident — a generic foreign URL near a login is too weak. |
| BITB-N4 | NEGATIVE — ordinary login page, no fake window [LIVE] | Visit a normal third-party login that is NOT a brand and has no in-page browser chrome (e.g. a self-hosted plain `<form>` login over http). `bitb.fakeWindow` is false. | **No detection.** No incident. |
| BITB-N5 | NEGATIVE — brand URL text but no password field [LIVE] | Self-built page that draws a fake window + `https://paypal.com` bar text but contains NO `input[type=password]` within 5 ancestors. `nearLogin` fails. | **No detection.** No incident. |
| BITB-N6 | NEGATIVE — brand URL inside a real link [LIVE] | Page where the brand URL text sits inside an `<a href>` (`el.closest("a")` skips it) near a login — e.g. a "Sign in with Google" anchor on a self-hosted page. | **No detection** (links legitimately show URLs). No incident. |
| BITB-N7 | NEGATIVE — footer/breadcrumb showing your own URL [LIVE] | Self-hosted login page whose footer shows the page's OWN URL or a `support@gmail.com` email. `spoofIn` ignores same-registrable host and rejects bare `@host` (not an address bar). | **No detection.** No incident (the email-in-footer FP this guard fixed). |
| BITB-N8 | NEGATIVE — real SaaS login with embedded iframe widget [LIVE] | `https://github.com/login`, `https://login.salesforce.com` — real IdP pages with iframes/URL text near the form. Brand-own-domain guard + structural URL checks hold. | **No detection.** No incident. |
| BITB-E1 | EDGE — spoof host == page host [LIVE] | Self-host the structural fixture on a host whose registrable domain equals the spoofed URL's (e.g. serve a page that spoofs `example.com` from `example.com`). `spoofIn` returns null (same site). | **No detection.** Confirms same-site address text is never a spoof. |
| BITB-E2 | EDGE — bar text too short / too long [LIVE] | Self-built page with address-bar text under 8 chars or over 120 chars near a login. Structural scan length-gate (`t.length < 8 || > 120`) skips it. | **No detection.** Validates the leaf-scan bounds. |
| BITB-C1 | COMBO — BITB on a look-alike domain (with homograph) [LIVE] | Self-host the brand BITB fixture on a confusable host (e.g. `paypa1.com`-style local host you control). Homograph (domain-shape) + bitb both fire on the same page. Arbiter corroborates. | **ONE** incident, highest severity (**HIGH**) → **BLOCK page**; the other feature appears under `contributing_features`/evidence. Not two incidents. |
| BITB-C2 | COMBO — allowlisted domain suppresses BITB [LIVE] | Add your test host's registrable domain to the org `allowlist` bundle, then open the brand BITB fixture (BITB-P1) on it. Arbiter `isAllowlisted` suppresses before enforcement. | **No detection, no block, no incident** — zero noise on an admin-vouched domain. Remove from allowlist → BITB-P1 fires again (HIGH, block). |
| BITB-C3 | COMBO — self-to-self email/file delivery [LIVE] | Compose an email to yourself or save an `.html` file you authored containing the structural BITB page, then open it from a served `http(s)` location (not `file://`). Mirrors the QR-in-Gmail self-delivery flow for a realistic live positive. | **HIGH**, **BLOCK page**, incident `bdr:bitb`, `spoofed_brand` matching the spoofed bar. (Open over http(s); content sensor does not run on `file://`.) |

---

## 6. Credential-Reuse

Same password (salted hash) submitted across two or more distinct sites, flagging password-reuse exposure (privacy-preserving: only a salted hash ever leaves the page).

Fires only when the same `passwordHash` is seen on 2+ distinct registrable domains, AND only on an `isNewDomain` transition (no repeat-fire on the same site). Severity `medium` / score 60 normally; escalates to `high` / score 85 if the current domain is a brand look-alike (`matchBrandLookalike`). State persists across restarts (`chrome.storage`) and is registrable-domain keyed (subdomains of the same site = same domain, no fire). Feature key `credential-reuse` / `bdr:credential-reuse`. Note: this feature never shows a block page even at `high` — escalation raises severity, not enforcement (it remains banner / dashboard-only). MITRE T1078.

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| CR-P1 | POSITIVE [FIXTURE] | Reuse across 2 sites. Reload extension first (clears nothing — memory persists, so see CR-N1). Open `credential-reuse-a.html` on `localhost:4005`, submit pw `P@ssw0rd123` (silent, first use), then open `credential-reuse-b.html` on `http://127.0.0.1:4005` (different registrable host) and submit the **same** pw. | **MEDIUM** (score 60). Enforcement: **BANNER / dashboard-only** (no block page even though hygiene incident). Dashboard incident created, feature key `credential-reuse` (`bdr:credential-reuse`); evidence lists `reused across: localhost, 127.0.0.1`. MITRE T1078. |
| CR-P2 | POSITIVE [FIXTURE] | 3-app reuse chain via `credreuse/credreuse-tests.html`. Click steps 1→2→3 in order (`corpportal` → `partnerapp` → `vendorportal`, all pw `Hunter2!`). | Step 1 silent (first use). Steps 2 & 3 each fire **MEDIUM**, banner/dashboard-only. Step-3 incident `app_count: 3`, headline lists all three registrable domains. One incident per new-domain transition. |
| CR-P3 | POSITIVE [LIVE] | Self-built reuse across two real-feeling sites you own. Serve two pages on **different hostnames** over http(s) (e.g. `http://app-one.localhost:8080` and `http://app-two.localhost:8080`, or two different ports/hosts you control). Submit the same password on both. Use only your OWN pages — never a third party. | 2nd submit fires **MEDIUM**, banner/dashboard-only, incident `credential-reuse`, `app_count: 2`. Confirms cross-site detection on live http origins. |
| CR-P4 | POSITIVE (escalation) [LIVE] | Reuse where the **2nd site is a brand look-alike**. First submit pw `Z3phyr$94kQ` on a benign page you host (e.g. `http://app-one.localhost:8080`). Then submit the **same** pw on a self-hosted look-alike domain you control that `matchBrandLookalike` flags (e.g. `accounts-google.localhost` / `paypa1.localhost`). | Escalates to **HIGH** (score 85). Banner/dashboard incident `credential-reuse`; evidence adds `current domain is a look-alike of <brand>`. (Note: feature still does not show a block page — escalation raises severity, not enforcement.) |
| CR-P5 | POSITIVE (persistence) [FIXTURE] | Reuse detected **across browser/SW restart**. Submit pw `Hunter2!` on site A, then fully restart Chrome (or let the service worker sleep), reopen, submit same pw on a different site B. | 2nd submit still fires **MEDIUM** — `seen` map rehydrated from `chrome.storage` (`bdr_cred_reuse`) before the event is processed; no hydration-race miss. |
| CR-N1 | NEGATIVE (first use) [FIXTURE/LIVE] | First-ever submit of a brand-new password on any single site (`credential-reuse-a.html` step 1, fresh extension state). | **No detection.** `others.length === 0` → returns null. A password must appear on a 2nd distinct site before anything fires. |
| CR-N2 | NEGATIVE (different pw) [FIXTURE] | Submit pw `P@ssw0rd123` on Site A, then a **different** pw on Site B (`credential-reuse-b.html`). | **No detection** — different hash, no prior domain set. (Per BDR_TEST_SPEC §3.) |
| CR-N3 | NEGATIVE (same site re-login) [LIVE] | Real legit site: log in to `accounts.google.com`, log out, log in again with the **same** password. Repeat on `outlook.office.com` and `github.com` individually (no cross-site reuse). | **No detection** on repeat submits — `isNewDomain` is false the 2nd time on the same registrable domain (`!isNewDomain` → null). Legit re-auth never flags. |
| CR-N4 | NEGATIVE (subdomains = one site) [LIVE] | Log in across subdomains of **one** real provider, e.g. `mail.google.com` then `accounts.google.com` (both registrable `google.com`), same Google password. | **No detection** — `registrable()` collapses both to `google.com`; not "2 distinct sites." Guards the most common legit FP (one identity provider, many subdomains). |
| CR-N5 | NEGATIVE (SSO, one IdP) [LIVE] | Real SSO flow: authenticate once at `login.microsoftonline.com`, then access multiple apps (`outlook.office.com`, `teams.microsoft.com`) that redirect back to the same IdP — you only ever **type** the password at the Microsoft IdP. | **No detection** — password is entered on a single registrable domain (`microsoftonline.com`); downstream apps receive tokens, not the password. Legit SSO is not reuse. |
| CR-N6 | NEGATIVE (no password field) [LIVE] | Navigate/submit a form that carries no password (search box, newsletter signup) on any real site (`github.com` search, `google.com` query). | **No detection** — no `credential` event / empty `passwordHash` → `if (!hash) return null`. |
| CR-N7 | NEGATIVE (unresolvable domain) [FIXTURE] | Trigger a credential event whose host has no registrable domain (e.g. bare `localhost`/IP-only context where `registrable()` returns null, or an internal hostname). | **No detection** — `if (!reg) return null`. No incident, no half-tracked state. |
| CR-N8 | NEGATIVE (privacy / no plaintext) [FIXTURE/LIVE] | Submit any password and inspect the `bdr-credential` message in the SW console / network. | **No plaintext leaves the page** — only a salted hash is present in the event. Privacy guard verified (BDR_TEST_SPEC §3 privacy check); a failed/absent hash would mean no detection at all (see CR-N6). |
| CR-E1 | EDGE (boundary, exactly 2) [LIVE] | Reuse on exactly two distinct registrable domains you host, then submit the same pw a **third** time on one of those same two domains. | First cross-site submit fires **MEDIUM**; the 3rd submit (already-seen domain) is **silent** (`!isNewDomain`). Fires once per *new* domain only — no duplicate-incident spam. |
| CR-C1 | COMBO (reuse + look-alike + other phishing signal) [LIVE] | On a self-hosted brand look-alike page that also reuses a previously-seen password (combine CR-P4 with a homograph/brand-impersonation trigger on the same domain). | Credential-reuse contributes **HIGH** (look-alike escalation). Arbiter yields **exactly ONE** dashboard incident (highest-severity headline; other features under `contributing_features`/evidence). If a co-firing feature is block-class (e.g. homograph HIGH), the block page is owned by that feature — credential-reuse itself never adds a block page. |

---

## 7. Password-strength

Flags WEAK passwords submitted to any login (on-device score; plaintext never leaves the page), as a LOW hygiene nudge.

> Mechanics that drive every expected result below. The password is scored **on-device** in `content.js` (`scorePassword`); only `{score,label,length,classes,reasons}` is sent — never the plaintext. The feature fires only when `score < 40` (`WEAK_THRESHOLD`) **and** `registrable(domain)` is truthy. Severity is always **low** → enforcement is a **BANNER** (in-page hygiene card, never a block or warn-interstitial). Each fire creates one dashboard incident under feature key **`bdr:password-strength`** (MITRE T1110). Capture needs `password.length >= 4` and de-dupes repeat submits of the same value. Reference scores (verified): `123456`/`password`/`qwerty123`/`abc123` = **12**, `aaaaaa` = **8**, `Password1!` = **22**, `Abcd1234` = **22**, `P@ss1` = **44**, `Hunter2!`/`Summer2024` = **56**, `Tr0ub4dor` = **52**, `Tk9!vQ2pLrXw#mN` = **84**.

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| PWS-P1 | POSITIVE [FIXTURE] | Open `pwstrength-tests.html`, click **weakapp1** (`123456`, digits-only, len 6). Auto-submits the login form. | **Detect.** score 12/100 (weak). LOW severity, **BANNER** hygiene card (no block). Incident `bdr:password-strength`; reasons include "too short", "few character types", "known common password", "predictable sequence/word". |
| PWS-P2 | POSITIVE [FIXTURE] | Click **weakapp2** (`password`). | **Detect.** score 12, weak. LOW, BANNER, incident. reason "known common password". |
| PWS-P3 | POSITIVE [FIXTURE] | Click **weakapp3** (`qwerty123`, keyboard pattern). | **Detect.** score 12, weak. LOW, BANNER, incident. reason "predictable sequence/word". |
| PWS-P4 | POSITIVE [FIXTURE] | Click **weakapp4** (`abc123`, len 6, 2 classes). | **Detect.** score 12, weak. LOW, BANNER, incident. reasons "too short", "known common password". |
| PWS-P5 | POSITIVE — repeated-char path [LIVE] | Self-host `login.html` over http (`python3 -m http.server` in the fixture dir), open `http://localhost:<port>/login.html?pw=aaaaaa`. Page auto-submits. | **Detect.** score 8, weak. LOW, BANNER, incident. reason "repeated single character" (clamps score ≤8). |
| PWS-P6 | POSITIVE — class-rich but predictable [LIVE] | On your self-hosted `login.html`, set `?pw=Password1!` (4 char types, len 10 — looks "complex" but contains "password"). | **Detect.** score **22**, weak (predictable-word clamp beats the length+class bonus). LOW, BANNER, incident. Demonstrates the penalty path overrides apparent complexity. |
| PWS-P7 | POSITIVE — sequence clamp [LIVE] | Self-hosted `login.html?pw=Abcd1234` (len 8, 3 classes, no reasons-free path). | **Detect.** score **22**, weak (`abcd`/`1234` sequence clamp). LOW, BANNER, incident. |
| PWS-P8 | POSITIVE — your own account, real SaaS [LIVE] | Sign in to a **real** app you own using a deliberately weak password you control (e.g. a throwaway test account on your own self-hosted app, or a personal dev login). Submit. | **Detect** if score <40. LOW, BANNER on that real host, incident `bdr:password-strength`. Confirms it fires on genuine logins, not only fixtures. Never use someone else's account. |
| PWS-N1 | NEGATIVE / FP — fair boundary [FIXTURE] | `pwstrength-tests.html` → **fairapp** (`Summer2024`, score 56). | **No detection.** Score ≥40 → silent. SW console quiet, **zero** incidents. (Key FP guard: "fair" passwords must not nag.) |
| PWS-N2 | NEGATIVE / FP — strong [FIXTURE] | **strongapp1** (`Tk9!vQ2pLrXw#mN`, score 84) and **strongapp2** (`x7$Bz!2qVm@9Lp`, score 80). | **No detection.** Silent, no incident. |
| PWS-N3 | NEGATIVE / FP — exact threshold [LIVE] | Self-hosted `login.html?pw=P@ss1` (score **44**, ≥40 despite len 5). Also try `?pw=Tr0ub4dor` (52). | **No detection.** `score >= 40` is excluded by the `>=` guard. Silent, no incident. Confirms the boundary is fired at <40 only. |
| PWS-N4 | NEGATIVE / FP — real Google login [LIVE] | Sign in to **accounts.google.com** with your real (strong) Google password. | **No detection** (your real password scores ≥40). No banner, no incident on a top-tier real IdP. If a banner ever appears here it is a false positive — fail. |
| PWS-N5 | NEGATIVE / FP — real Microsoft login [LIVE] | Sign in at **outlook.office.com** / **login.microsoftonline.com** with your real password. | **No detection.** Silent. Verifies no nag on sanctioned enterprise SSO. |
| PWS-N6 | NEGATIVE / FP — real GitHub login [LIVE] | Sign in to **github.com** with your real password. | **No detection.** Silent, no incident. |
| PWS-N7 | NEGATIVE — non-credential field [LIVE] | On any page, type a weak value into a search box or a non-password `<input>` and submit (no `type=password` field captured). | **No detection.** Feature only evaluates `credential` events from a real password submit; no event, no score, no incident. |
| PWS-N8 | NEGATIVE — too-short to capture [LIVE] | Self-hosted login, submit a 1–3 char password (e.g. `ab`). | **No detection.** Capture requires `value.length >= 4`; no `bdr-credential` event is emitted, so the feature never runs. |
| PWS-N9 | NEGATIVE — no domain [EDGE/LIVE] | Trigger a credential event where `registrable(domain)` resolves empty (malformed/blank host, e.g. `about:blank` form). | **No detection.** `registrable` falsy → `evaluate` returns null even for a weak score. Guard prevents a stray banner with no attributable site. |
| PWS-E1 | EDGE — IP-hosted fixture [LIVE] | Serve `login.html` on `http://127.0.0.1:<port>/login.html?pw=123456`. | **Detect.** `registrable` returns the IP itself (truthy) → feature fires; banner shows the IP as the domain. score 12, LOW, incident `bdr:password-strength`. (Confirms IP hosts are in-scope, not a guard gap.) |
| PWS-E2 | EDGE — de-dupe on resubmit [LIVE] | On a weak-password fixture, submit, then click Sign in again with the **same** value without changing it. | **One** incident only. The same password hash is de-duped (`lastSent`); the banner/incident does not duplicate on repeat submit. |
| PWS-E3 | EDGE — score boundary 39 vs 40 [LIVE] | Self-host two logins with crafted passwords scoring 39 and 40 respectively (use `node /tmp/score.mjs`-style check or `bdr/tests/pwstrength-range-verify.mjs` to pick values). | 39 → **detect** (banner, incident); 40 → **silent**. Pins the `< WEAK_THRESHOLD` boundary exactly. |
| PWS-C1 | COMBO — weak + credential-reuse [LIVE] | Self-host two apps (Site A, Site B on different registrable hosts). Submit the **same weak** password (e.g. `abc123`) on A then B. | **Exactly ONE** incident on Site B. Credential-reuse (**medium**) outranks password-strength (**low**) in the arbiter → headline = reuse; password-strength appears under contributing signals/evidence, not as a second incident. Enforcement = banner (highest of the two non-high tiers). |
| PWS-C2 | COMBO/ALLOWLIST — admin-vouched host [LIVE] | Add your weak-password fixture's registrable domain (or `*.localhost`) to the org **allowlist** bundle, reload extension, resubmit the weak password. | **No detection.** Allowlist suppresses entirely before evaluation — zero banner, zero incident, even though the password is weak. Confirms admins can clear residual FPs without code changes. |
| PWS-C3 | PRIVACY assertion [LIVE] | On any positive case, inspect the `bdr-credential` message / SW network POST while the banner fires. | Payload carries `passwordHash` + `strength {score,label,length,classes,reasons}` only — **no plaintext password** anywhere. SW log reads `…(hash + strength N/100 sent; password NOT sent)`. |

---

## 8. Webmail-Response

Phishing links & dangerous attachments inside an opened Gmail / Outlook message.

Browser-side response only (not native mail-flow quarantine). The feature runs **only when a webmail provider is recognised** — a real `mail.google.com` / `outlook.office(365).com` host, or, on the Test Range, a page that declares `<meta name="bdr-webmail" content="gmail">`. It then inspects the links and attachments the content sensor surfaces from the rendered message. Two distinct detection paths: **(a) a message LINK whose real (post-unwrap) destination resembles a brand** → `high` (BLOCK page); **(b) an ATTACHMENT with a dangerous executable extension** → `medium` (BANNER). All incidents key on feature `webmail-response` (`bdr:webmail-response`; `kind: webmail-link` or `webmail-attachment`). Link detection uses the full `lookalikeBrand()` engine (confusable, IDN-homograph, deceptive-subdomain, combosquat, typosquat) OR `matchBrandLookalike()`. `DANGEROUS_EXTENSIONS` = exe, scr, com, pif, bat, cmd, js, jse, vbs, vbe, wsf, hta, jar, msi, lnk, iso, img, ps1. Precision-first: link enforcement is a hard block only because confidence = 3 (≥ block threshold); attachments stay a non-blocking banner, and every legitimate-mail guard (real-brand links, in-domain wrapper links, benign attachments, allowlist) must stay silent.

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| WMR-P1 | POSITIVE — link, confusable [FIXTURE] | Open `http://wlinka.localhost:4005/phishing/webmail-response/webmail.html?provider=gmail&case=link` (Gmail mock) — body contains `<a href="http://micros0ft.com/verify">`. | DETECT. Severity **high**, enforcement **BLOCK page**. Headline "Phishing link in gmail message → look-alike of microsoft". Incident `bdr:webmail-response` (`kind: webmail-link`), MITRE T1566.002. |
| WMR-P2 | POSITIVE — link, Outlook provider [FIXTURE] | Open `http://wlinkb.localhost:4005/phishing/webmail-response/webmail.html?provider=outlook&case=link`. | DETECT. **high** / **BLOCK page**, provider `outlook`. Incident `bdr:webmail-response` (`kind: webmail-link`). |
| WMR-P3 | POSITIVE — link, IDN homograph [LIVE-SELF] | Compose an email **to yourself** in real Gmail; paste a link to `https://xn--pypal-4ve.com/login` (punycode → paypal). Open the received message. | DETECT. **high** / **BLOCK page**, "look-alike of paypal" (idn-homograph). Incident `bdr:webmail-response` (`kind: webmail-link`). |
| WMR-P4 | POSITIVE — link, deceptive subdomain [LIVE-SELF] | Email yourself a link `http://paypal.com.secure-login.xyz/` and open it in Gmail. | DETECT. **high** / **BLOCK page**, brand "paypal" used as subdomain (deceptive-subdomain). Incident `bdr:webmail-response`. |
| WMR-P5 | POSITIVE — link, combosquat [LIVE-SELF] | Email yourself `http://login-microsoft.io/verify` (brand + lure word) and open in Outlook web. | DETECT. **high** / **BLOCK page**, combosquat of microsoft. Incident `bdr:webmail-response`. |
| WMR-P6 | POSITIVE — link, typosquat [LIVE-SELF] | Email yourself `http://gooogle.com/` (recognised typo class) and open in Gmail. | DETECT. Link path still emits **high** / **BLOCK page** (webmail-response scores all `lookalikeBrand` hits at 80, regardless of homograph's own medium typo tier). Incident `bdr:webmail-response` (`kind: webmail-link`). |
| WMR-P7 | POSITIVE — link survives Gmail redirect unwrap [LIVE-SELF] | Email yourself the same `micros0ft.com` link; Gmail rewrites the anchor to `google.com/url?q=http://micros0ft.com/...`. Open it. | DETECT. Sensor unwraps `google.com/url?q=` to the real host before analysis → **high** / **BLOCK page**. (Verifies the wrapper does NOT mask the payload.) Incident `bdr:webmail-response`. |
| WMR-P8 | POSITIVE — link survives Outlook SafeLinks unwrap [LIVE-SELF] | Email yourself the look-alike link; open in Outlook web where it is rewritten to `*.safelinks.protection.outlook.com/?url=...`. | DETECT. SafeLinks `?url=` is unwrapped → real host analysed → **high** / **BLOCK page**. Incident `bdr:webmail-response`. |
| WMR-P9 | POSITIVE — attachment .exe [FIXTURE] | Open `http://watta.localhost:4005/phishing/webmail-response/webmail.html?provider=gmail&case=attachment` — attachment `invoice_2024.exe`. | DETECT. Severity **medium**, enforcement **BANNER** ("Suspicious message"), NO block page. Headline "Risky attachment in gmail message (.exe)". Incident `bdr:webmail-response` (`kind: webmail-attachment`), MITRE T1566.001. |
| WMR-P10 | POSITIVE — attachment .scr (Outlook) [FIXTURE] | Open `http://wattb.localhost:4005/...?provider=outlook&case=attachment` — `invoice_2024.scr`. | DETECT. **medium** / **BANNER**, provider `outlook`. Incident `bdr:webmail-response` (`kind: webmail-attachment`). |
| WMR-P11 | POSITIVE — attachment, other dangerous exts [LIVE-SELF] | Email yourself separate harmless-renamed files ending in each: `.js .vbs .hta .jar .msi .lnk .iso .ps1 .bat .cmd .com .pif .jse .vbe .wsf .img`. Open each message. | DETECT each. **medium** / **BANNER**, headline names the `.ext`. One `bdr:webmail-response` incident per message (`kind: webmail-attachment`). Confirms the full DANGEROUS_EXTENSIONS set fires. |
| WMR-N1 | NEGATIVE / FP — not a webmail page [LIVE] | Browse normally to `github.com`, `accounts.google.com`, `news.ycombinator.com` (any non-mail site, no `bdr-webmail` meta) with a look-alike link present in body text. | NO detection. `wm.provider` is null → feature returns early. SW console silent, no incident. |
| WMR-N2 | NEGATIVE / FP — clean mail, real-brand link + pdf [FIXTURE] | Open `http://wclean.localhost:4005/...?provider=gmail&case=clean` — legit link (`github.com`) + `report.pdf`. | NO detection. Real-brand registrable passes the FP guard; `.pdf` not in DANGEROUS_EXTENSIONS. Silent, no incident. |
| WMR-N3 | NEGATIVE / FP — genuine brand links in real mail [LIVE-SELF] | Email yourself a message containing only real links: `https://accounts.google.com/...`, `https://outlook.office.com/...`, `https://github.com/...`, `https://www.paypal.com/...`. Open in Gmail. | NO detection. Each registrable is in REAL_DOMAINS / aliases (incl. `office.com`, `live.com`) → `lookalikeBrand` returns null. Silent, no incident. |
| WMR-N4 | NEGATIVE / FP — brand as innocent substring [LIVE-SELF] | Email yourself links to `appleinsider.com` and `microsoftnews.example` (brand is a mere substring, not a token/lure combo). Open in Gmail. | NO detection. Combosquat tokeniser requires a brand token or brand+lure-word, not arbitrary substrings. Silent, no incident. |
| WMR-N5 | NEGATIVE / FP — same-origin / wrapper-only link [LIVE-SELF] | Email yourself a link that stays on the provider (e.g. a `mail.google.com` settings link) — no external host. Open it. | NO detection. Sensor drops links where `host === location.hostname`; nothing to flag. Silent, no incident. |
| WMR-N6 | NEGATIVE / FP — benign attachment types [LIVE-SELF] | Email yourself attachments `quarterly.pdf`, `photo.png`, `slides.pptx`, `data.csv`, `archive.zip`, `doc.docx`. Open the message. | NO detection. None of these extensions are in DANGEROUS_EXTENSIONS (note: `.zip`/`.docx` deliberately excluded to avoid FP noise). Silent, no incident. |
| WMR-N7 | NEGATIVE / FP — extensionless / trailing-dot name [LIVE-SELF] | Email yourself an attachment named `invoice` (no extension) or `README.` . Open the message. | NO detection. `extOf()` yields empty → not in set. Silent, no incident. |
| WMR-E1 | EDGE — combo: look-alike link + .exe in one message [FIXTURE] | Open `http://wboth.localhost:4005/...?provider=gmail&case=both` — both a `micros0ft.com` link and `invoice_2024.exe`. | DETECT. Both signals emit; arbiter fuses to ONE incident, headline = highest severity (the **high** link → **BLOCK page**). The medium attachment attaches under `evidence` / `contributing_features`. Single `bdr:webmail-response` incident. |
| WMR-E2 | EDGE — allowlist suppression [LIVE] | Admin adds the webmail provider's domain (or the message domain) to the org allowlist; reopen WMR-P1/WMR-P9 scenario. | NO detection. `policy.isAllowlisted()` suppresses the signal before enforcement — zero noise on a vouched domain. No block, no banner, no incident. |
| WMR-E3 | EDGE — case-insensitive / uppercase extension [LIVE-SELF] | Email yourself an attachment named `Payload.EXE` (mixed case). Open it. | DETECT. `extOf()` lowercases before the set lookup → **medium** / **BANNER**. Incident `bdr:webmail-response` (`kind: webmail-attachment`). |
| WMR-E4 | EDGE — double extension trick [LIVE-SELF] | Email yourself `invoice.pdf.scr`. Open it. | DETECT. Only the final extension `scr` is matched (in set) → **medium** / **BANNER**. Incident `bdr:webmail-response`. |
| WMR-E5 | EDGE — repeat within dedup window [LIVE-SELF] | Re-open the same WMR-P1 phishing message twice inside 60 s. | ONE incident only. The second open is suppressed (same dedup key, same `block` tier ≤ prior) — no duplicate dashboard spam. Block page still shown to the user. |

---

## 9. QR / Quishing

Decodes QR codes on a page (or in webmail) and neutralises any whose target is a brand look-alike domain before the user can scan it.

Detection is delegated to the full homograph engine (`lookalikeBrand` → confusable/IDN/deceptive-subdomain/combosquat/typosquat) plus the confusable-only `matchBrandLookalike` fallback. A clean target is a no-op, so the **`REAL_DOMAINS` allowlist guard governs every false-positive case below**. The verdict is always normalised to **severity `high`, score 82** when it fires; enforcement is **element-NEUTRALIZE only** (blur the QR image + "Blocked by VIYUH BDR" badge, scanning disabled) — **never a page block**, so the inbox/page stays usable. Confirmed-malicious QR images are fingerprinted so a later *download* of the same image is cancelled (download guard). Every positive raises a dashboard incident under feature key **`qr-quishing`** (`bdr.category=phishing`, `kind=quishing`, MITRE **T1566.002**).

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| QR-P1 | POSITIVE — confusable [FIXTURE] | Serve your own `qr-quishing/qr.html?case=paypal` (QR encodes `paypa1.com`) over http://localhost:4005. In-page sensor decodes it. | DETECT. `high` / score 82. Element-NEUTRALIZE (QR blurred + badge, scan disabled); **no page block**. Incident `qr-quishing` (brand paypal, kind quishing, T1566.002). |
| QR-P2 | POSITIVE — confusable+IDN [FIXTURE] | `qr.html?case=ms` (QR → `micros0ft.com`) and an IDN target `xn--pypal-4ve.com`. | DETECT both. `high`/82. NEUTRALIZE each QR image; no block. Incident `qr-quishing` (microsoft / paypal). |
| QR-P3 | POSITIVE — combosquat [FIXTURE] | `qr.html?case=amazon` (QR → `amazon-secure.com`) and `login-microsoft.io`. | DETECT. `high`/82 (verdict normalises combosquat to high). NEUTRALIZE; no block. Incident `qr-quishing`. |
| QR-P4 | POSITIVE — deceptive subdomain [FIXTURE] | `qr.html?case=subdomain` (QR → `paypal.com.account-verify.xyz`). | DETECT. `high`/82. NEUTRALIZE; no block. Incident `qr-quishing` (evidence: kind deceptive-subdomain). |
| QR-P5 | POSITIVE — typosquat target [FIXTURE] | Build a QR encoding `gooogle.com` (1-edit typo), embed in your self-hosted page. | DETECT. Verdict normalised to `high`/82 even though homograph alone would rate this medium. NEUTRALIZE; no block. Incident `qr-quishing`. |
| QR-P6 | POSITIVE — multiple bad QRs, one signal [FIXTURE] | Page with 2+ QR images → `paypa1.com` **and** `micros0ft.com`. | DETECT once. Single aggregated incident, headline "2 QR codes point to look-alike domains (paypal, microsoft)". **Both** images neutralised (`qr_targets` array); no block. |
| QR-P7 | POSITIVE — LIVE QR-in-Gmail (in-page path) [LIVE] | Compose an email **to yourself** in real Gmail; insert `qr-ms.png` (→ `micros0ft.com`) inline (per `phishing-email-template.txt`); open the message on `mail.google.com`. | QR shows "Checking QR…" then flips to "Blocked by VIYUH BDR — scanning disabled". `high`/82. Inbox stays usable, **no page block**. SW log `qr-quishing (mail)`. Incident `qr-quishing`. |
| QR-P8 | POSITIVE — LIVE cross-origin offscreen decode [LIVE] | Same Gmail self-test, but the QR is a Gmail attachment served via `googleusercontent` CDN (canvas-tainted, unreadable in-page). | Content script forwards src → offscreen doc fetches+decodes → classified by same look-alike logic → `high`/82 NEUTRALIZE. Incident `qr-quishing` (confidence 4). No block. |
| QR-P9 | POSITIVE+EDGE — LIVE download-block follow-through [LIVE] | After QR-P7/P8 flags the QR, click *Download* on that same QR image/attachment. | Download is **cancelled** by the download guard (`isMaliciousQrDownload` matches the fingerprint key — `att:msg:attid` or `res:origin/path`). Survives an MV3 worker recycle via `storage.session`. No new incident. |
| QR-N1 | NEGATIVE / FP — legit QR (allowlisted brand) [FIXTURE] | `qr.html?case=clean` (QR → `github.com`). | **NO detection.** `github.com` is in `REAL_DOMAINS` → look-alike check is a no-op. "Checking…" spinner CLEARS, QR stays scannable. No incident. Enforcement: none. |
| QR-N2 | NEGATIVE / FP — LIVE real-brand QR [LIVE] | Build a self-hosted page (or self-email) with a QR encoding a genuine login URL: `accounts.google.com`, then `outlook.office.com`, then `paypal.com`, then `amazon.com`. | **NO detection** for any — all canonical/alias domains in `REAL_DOMAINS`. Spinner clears, QR scannable, no incident. (This is the #1 precision guard — must stay silent.) |
| QR-N3 | NEGATIVE / FP — non-brand benign domain [LIVE/FIXTURE] | QR encoding `example.com` / `wikipedia.org` / your own `localhost` URL (no brand resemblance). | **NO detection** (no brand match, look-alike returns null). No incident, no neutralize. |
| QR-N4 | NEGATIVE / FP — non-URL QR payload [FIXTURE] | QR encoding plain text, a phone number, or a Wi-Fi/`WIFI:` string (not `http(s)://`). | **NO detection.** Non-`https?` payloads are filtered before classification (`hostnameOf` null / regex gate). Spinner clears. No incident. |
| QR-N5 | NEGATIVE / FP — image that isn't a QR [LIVE] | Self-email or page with an ordinary photo/logo image (no QR). | **NO detection.** Decode returns nothing; "Checking…" loader clears on all non-malicious probed srcs (`bdr-qr-clear`). No FP, no lingering spinner. |
| QR-N6 | NEGATIVE / FP — page with no QR at all [LIVE] | Open `github.com`, `accounts.google.com`, a normal article — pages with zero QR images. | **NO detection.** `evaluate` returns null on empty `qr` array. SW console quiet. No incident. |
| QR-E1 | EDGE — late-loading QR [FIXTURE] | Open the fixture while the QR `<img>` is still loading on first scan, then re-open / let it settle. | On a clean re-scan the decoded target is classified; malicious → neutralize, clean → clears. No duplicate incident (probe de-duped by src for 5 min TTL). |
| QR-E2 | EDGE — re-probe de-duplication [LIVE] | Reload the same Gmail message / refresh the fixture several times within 5 min. | Same QR src is **not** re-decoded (recentProbe TTL); at most one incident per malicious target. No incident spam. |
| QR-E3 | EDGE — many QRs, cap [FIXTURE] | Page with >6 cross-origin QR images. | Only first 6 are probed per batch (slice cap); malicious ones among them still neutralised. No crash/hang (decoded concurrently). |
| QR-C1 | COMBO — mixed clean + malicious QRs [FIXTURE] | One page with `github.com` QR **and** `paypa1.com` QR. | Only the `paypa1.com` image is neutralised + raises ONE `qr-quishing` incident; the `github.com` QR clears and stays scannable. Demonstrates element-level (not page-level) enforcement. |
| QR-C2 | COMBO — quishing inside a phishing email body [LIVE] | Self-email combining a malicious QR (→ `micros0ft.com`) with phishing lure text ("scan to reset your password"). | `qr-quishing` neutralises the QR (`high`/82); if webmail-response/homograph also evaluate body links the arbiter yields **exactly one** highest-severity incident with the others under `contributing_features`. No page block; inbox usable. |

---

## 10. phish-reputation

Flags any URL/domain a sensor surfaces (page, QR target, webmail link, form post-target) that the BDR data lake or live threat-intel feeds know to be phishing — even when it is NOT a brand look-alike.

> Precision contract: this feature fires ONLY on a positive backend hit (`bdr:phish:*` lake or feed). A clean/unknown domain is always allowed. Severity is fixed `high`, score `max(75, confidence||80)`, MITRE `T1566.002`, incident key `bdr:phish-reputation`. Re-checks of the same value are suppressed for 10 min (`RECHECK_MS`); a backend error returns silently (no detection on outage). Candidates come from four sensor paths: navigation page domain, QR target URLs, webmail link URLs, and login-form post-target domains. `hostOf` strips `www.` and returns null on a bad URL (null candidates dropped via the `add` guard).

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| REP-P1 | POSITIVE [FIXTURE] | Open `http://localhost:4005/reputation.html`; click `http://secure-update-portal.xyz` (seeded in lake, NOT a look-alike). Navigation event → `domain` candidate. | HIGH; BLOCK page; incident `bdr:phish-reputation`; headline `Known phishing domain (bdr-lake)…`; evidence shows `source: bdr-lake`. |
| REP-P2 | POSITIVE [FIXTURE] | Same page, click `http://account-verify-now.top` and `http://mfa-reauth-required.app` (both seeded). | HIGH each; BLOCK page; one incident per distinct domain. |
| REP-P3 | POSITIVE — navigation path [LIVE] | Self-host a page on your own box and seed its host into the lake: add a test domain to `bdr/bdr-backend/src/data/phish-seed.js`, `npm run seed`, then serve your page on that host (or point `/etc/hosts` at it) over http(s) and navigate to it. | HIGH; BLOCK page; incident; proves the live navigation→domain detection path end-to-end. |
| REP-P4 | POSITIVE — QR path [LIVE] | Build your OWN fixture page that renders a PNG QR (http-served, not file://) encoding a seeded bad URL (e.g. `http://secure-update-portal.xyz`). DOM event → `event.qr` → `url` candidate. | HIGH; WARN interstitial / element-NEUTRALIZE on the QR; incident; headline `Known phishing url (bdr-lake) — <decoded>`. |
| REP-P5 | POSITIVE — webmail-link path [LIVE] | In your OWN Gmail/Outlook, compose an email to yourself whose body links to a seeded bad URL; open it. DOM event → `event.webmail.links` → `url` candidate. (On localhost use the `<meta name="bdr-webmail">` affordance.) | HIGH; BANNER on the message + element-NEUTRALIZE the link; incident `bdr:phish-reputation`. |
| REP-P6 | POSITIVE — form post-target path [LIVE] | Build your OWN login page whose `<form action>` posts to a seeded bad domain; load it. DOM event → `event.login.postTargets` → `domain` candidate. | HIGH; BLOCK page; incident; evidence `reputation hit: <postTarget>`. |
| REP-P7 | POSITIVE — confidence floor [FIXTURE] | Trigger any seeded hit returning `confidence < 75` (or none). | Score clamped to ≥75 (`max(75,…)`); still HIGH; incident created. |
| REP-N1 | NEGATIVE — clean nav [FIXTURE] | On `reputation.html` click `http://example.com` (explicitly NOT in lake). | No detection; SW console stays quiet; no block page; no incident. |
| REP-N2 | NEGATIVE — real banking/SaaS login [LIVE] | Navigate to `accounts.google.com`, `outlook.office.com`, `github.com/login`, `login.microsoftonline.com`. None are in the lake. | No detection; no block; no incident — top FP-guard for the precision-first rule. |
| REP-N3 | NEGATIVE — legit webmail links [LIVE] | In your own Gmail, open a normal newsletter/receipt whose links go to real senders (e.g. `amazon.com`, `slack.com`). | No detection on any link; no banner; no incident. |
| REP-N4 | NEGATIVE — legit QR [LIVE] | Build your own page with a PNG QR encoding `https://github.com` (clean). | No detection; no interstitial; no incident. |
| REP-N5 | NEGATIVE — legit form post-target [LIVE] | Real login form posting same-origin to a clean SaaS (e.g. your org's Okta tenant `*.okta.com`). | No detection; no block; no incident. |
| REP-N6 | NEGATIVE — backend down [LIVE] | Stop bdr-backend (or block `reputationBulk`), then click a seeded bad link on `reputation.html`. `api.reputationBulk` throws → `catch` returns. | No detection; no block; no incident — fail-open, never a false positive on outage. |
| REP-N7 | NEGATIVE — malformed URL [FIXTURE] | Page/QR containing a non-parseable URL (e.g. `javascript:…`, `mailto:`, empty). `hostOf` returns null → `add` guard drops it. | No candidate built; no backend call; no detection. |
| REP-E1 | EDGE — www-strip equivalence [LIVE] | Seed `secure-update-portal.xyz`; navigate to `http://www.secure-update-portal.xyz`. `hostOf` strips `www.` before lookup. | HIGH; BLOCK page; incident — matches despite `www.` prefix. |
| REP-E2 | EDGE — dedupe / recheck window [FIXTURE] | Click a seeded bad domain, get the hit, then re-click the SAME domain within 10 min. `fresh()` suppresses re-check. | First click: HIGH + incident. Second within 10 min: NO new backend call, no duplicate incident. After >10 min: fires again. |
| REP-E3 | EDGE — newly-seeded indicator [LIVE] | Add a new indicator to `phish-seed.js`, run `npm run seed`, reload page, open the just-seeded domain. | Previously-clean domain now HIGH + block + incident — proves live lake refresh. |
| REP-C1 | COMBO — reputation + homograph co-fire [LIVE] | Self-host a look-alike domain (e.g. `paypa1.com`-style) AND seed it into the lake; navigate to it. Both `phish-reputation` (HIGH) and homograph fire. | Arbiter emits exactly ONE incident; headline = highest severity (HIGH); the other feature appears under `contributing_features`/evidence — no double-count. |
| REP-C2 | COMBO — reputation QR + quishing [LIVE] | Your own page renders a QR encoding a domain that is BOTH a look-alike and lake-seeded. | Single incident (arbiter de-dups); HIGH; QR element-NEUTRALIZED; reputation listed as a contributing signal. |

---

## 11. Cross-feature / Arbiter combo cases

These exercise the arbiter directly: when MULTIPLE features fire on one page, the result must be **exactly ONE** arbitrated dashboard incident — highest severity / highest confidence wins the headline, the rest attach under `contributing_features` / evidence. Also covers warn→block escalation and allowlist suppression across features.

| ID | Type | Scenario / how to trigger | Expected result |
|----|------|---------------------------|-----------------|
| ARB-C1 | COMBO — homograph + content kit → escalate to BLOCK [LIVE] | Self-host an AiTM/sso-clone fixture page served from a look-alike host you control (e.g. `paypa1.com`-style). homograph (conf 2, would warn alone) + content detector co-fire. | **ONE** incident (not two). Arbiter escalates **WARN → hard BLOCK page** (corroborated, content detector is confident). Headline = highest severity; homograph under `contributing_features`/evidence. |
| ARB-C2 | COMBO — look-alike + brand-impersonation → ONE incident [LIVE] | Self-host the brand login fixture on a confusable host so `homograph` (conf 2) and `brand-impersonation` (conf 3) both fire on the same nav. | **ONE** incident, **HIGH**, **BLOCK**. Headline owned by the higher-confidence feature; the other under evidence. Corroboration (2+ features) blocks even independent of the confidence floor. No duplicate. |
| ARB-C3 | COMBO — sso-clone owns headline over brand-impersonation [LIVE] | Self-host a pixel/visible-text brand login clone (triggers `sso-clone`, conf 5) on a look-alike host (also triggers `brand-impersonation`, conf 3). | **ONE** incident, **HIGH**, **BLOCK page**. Headline attributed to `sso-clone` (most specific, highest confidence); brand-impersonation under evidence. Single dashboard incident, single block. |
| ARB-C4 | COMBO — kit + relay on one page, Signal-1 wins [LIVE] | Self-host a page with BOTH an `evilginx` meta marker AND a cross-origin password POST to `login.microsoftonline.com`. | **HIGH**, **BLOCK page**. AiTM Signal 1 (kit) wins, score 92, kit evidence. Exactly ONE `bdr:aitm` incident — the relay signal does not create a second. |
| ARB-C5 | COMBO — weak + credential-reuse, severity ordering [LIVE] | Self-host Site A and Site B (different registrable hosts). Submit the same **weak** password on A then B. | **Exactly ONE** incident on Site B. Credential-reuse (**medium**) outranks password-strength (**low**) → headline = reuse; password-strength under contributing signals. Enforcement = banner (no block — neither feature is block-class). |
| ARB-C6 | COMBO — reputation + homograph co-fire [LIVE] | Self-host a look-alike domain (e.g. `paypa1.com`-style) AND seed it into the lake; navigate. `phish-reputation` (HIGH, conf-driven block) + homograph both fire. | **ONE** incident; headline = highest severity (HIGH) → BLOCK page; homograph under `contributing_features`/evidence. No double-count. |
| ARB-C7 | COMBO — quishing + reputation on one QR [LIVE] | Your own page renders a QR encoding a domain that is BOTH a look-alike AND lake-seeded. | **ONE** incident (arbiter de-dups); HIGH; QR element-NEUTRALIZED; reputation listed as a contributing signal. No page block (quishing enforcement is element-level). |
| ARB-C8 | COMBO — webmail link + .exe attachment in one message [FIXTURE] | Open `http://wboth.localhost:4005/...?provider=gmail&case=both` — a `micros0ft.com` look-alike link AND `invoice_2024.exe`. | **ONE** incident. Headline = highest severity (the HIGH link → **BLOCK page**); the medium attachment attaches under evidence. Single `bdr:webmail-response` incident. |
| ARB-A1 | ALLOWLIST — suppression across features [LIVE] | Take any page that WOULD fire one or more features (e.g. ARB-C3 sso-clone + brand-impersonation). Add its registrable domain (or `*.host`) to the org `allowlist` bundle, reload, re-open. | **No detection from ANY feature.** `policy.isAllowlisted` suppresses every signal before the arbiter verdict — zero block, zero banner, zero incident. Remove from allowlist → the combo fires again. |
| ARB-A2 | ALLOWLIST — multi-feature dedup window [LIVE] | Off-`localhost`, open the same multi-feature positive page twice within the arbiter dedup window (~60 s). | Second open **suppressed** by the dedup key (same host/verdict tier) — still ONE incident. Re-open after the window, or use a distinct `*.localhost` subdomain, to fire again (by design, not a miss). |

---

## 12. Results tracking checklist

Fill in counts as you run each section. `# cases` matches the rows authored above.

| Feature | # cases | Pass | Fail | Notes |
|---------|---------|------|------|-------|
| 1. Homograph / Typosquatting / Combosquatting | 26 | | | |
| 2. AiTM / Reverse-Proxy | 23 | | | |
| 3. SSO-Clone (AiTM anti-evasion) | 22 | | | |
| 4. Brand-Impersonation | 19 | | | |
| 5. Browser-in-the-Browser (BITB) | 20 | | | |
| 6. Credential-Reuse | 14 | | | |
| 7. Password-strength | 23 | | | |
| 8. Webmail-Response | 23 | | | |
| 9. QR / Quishing | 20 | | | |
| 10. phish-reputation | 19 | | | |
| 11. Cross-feature / Arbiter combo | 10 | | | |
| **TOTAL** | **239** | | | |
---

## 13. Wave 1 hardening — new precision / FP cases (v0.5.0)

Added by the Wave 1 hardening pass (see `PHISHING_HARDENING_CHANGELOG.md`). These lock in the false-positive fixes — the NEGATIVE rows **must stay silent**, the "still fires" rows **must still fire**. All are [LIVE]-checkable (self-host the page over http, or just navigate) and covered by the automated suite.

| ID | Type | Scenario | Expected |
|----|------|----------|----------|
| W1-HG-N1 | NEGATIVE [LIVE] | Navigate to `amazon-jobs.com` / `google-developers.com` / `microsoft-careers.com` / `okta-status.com` | **Silent** — brand + non-lure word is a legit subsidiary pattern (combosquat lure-gate) |
| W1-HG-N2 | NEGATIVE [LIVE] | Navigate to `appleid.com` / `applepay.com` | **Silent** — `id`/`pay` demoted (Apple ID / Apple Pay are real) |
| W1-HG-P1 | POSITIVE [LIVE] | Navigate to `paypal-secure.com` / `amazon-verify.com` / `login-microsoft.io` | **Still fires** HIGH homograph (real lure word present) |
| W1-BI-N1 | NEGATIVE [LIVE] | A login page titled "Pineapple Co" or "Citizen Bank" on a non-brand domain | **Silent** — whole-word brand match (no apple⊂pineapple / short-key⊂word) |
| W1-BI-N2 | NEGATIVE [LIVE] | A real localized brand login: `amazon.de`, `apple.co.uk`, `paypal.me` (title names the brand) | **Silent** — exact brand label on a common legit TLD |
| W1-BI-N3 | NEGATIVE [LIVE] | A SaaS login titled "Acme — Powered by Okta" / "Secured by Auth0" on `acme-app.com` | **Silent** — provider-credit phrasing stripped |
| W1-BI-P1 | POSITIVE [LIVE] | "PayPal — Sign in" page on `paypal.tk` (abuse TLD) | **Still fires** brand-impersonation |
| W1-BITB-N1 | NEGATIVE [FIXTURE] | BITB fake window whose address bar shows `purchase-deals.com` or `evil.com/microsoft` | **Silent** — brand matched on host only, at a word boundary |
| W1-BITB-P1 | POSITIVE [FIXTURE] | BITB fake window showing `https://login.microsoftonline.com/` | **Still fires** HIGH (brand-containing real login host) |
| W1-CR-1 | EDGE [LIVE] | Reuse a password on a look-alike domain (`micros0ft.com`) | credential-reuse fires **medium/banner** (advisory) — the BLOCK comes from homograph; the two **corroborate** |
| W1-REP-P1 | POSITIVE [LIVE] | Open `verify.<known-bad-lake-domain>` (subdomain of a seeded lake entry) | **Fires** phish-reputation (parent-domain walk) |
| W1-WMR-1 | EDGE [LIVE] | Webmail message with the same look-alike link twice; re-open the message | **One** signal, no re-spam on re-scan (per-host + cross-scan dedup) |
| W1-SEC-1 | SECURITY [LIVE] | (manual) A page with a QR `<img src>` pointing at `http://169.254.169.254/...` or `http://localhost/...` | Offscreen fetch **blocked** (SW console: "QR fetch blocked"); no credentialed request to internal host |
