# Safe-Link — scoring methodology

A verdict is produced in two independent passes over the **address**. Nothing
behind the link is fetched.

## 1. Canonicalisation

Before anything is scored the address is normalised, so that the same link always
produces the same verdict and the same hash:

- Defanged forms are restored: `hxxp://`, `hxxps://`, `[.]`, `(.)`, `{.}`.
- Zero-width and bidirectional control characters are stripped — they are used to
  hide a domain's real spelling.
- A missing scheme is assumed to be `https`.
- Host is lowercased; a trailing dot is dropped; a default port (80 for http, 443
  for https) is dropped.
- 26 known tracking parameters (`utm_*`, `fbclid`, `gclid`, and similar) are
  removed, and the remaining query parameters are sorted.
- The result is hashed with sha256. That hash is what the exception list, the
  verdict reuse and all statistics key on.

An address that cannot be parsed is rejected with `invalid_url` rather than
scored.

## 2. Deterministic address rules

18 rules, each with a fixed weight, all observable in the address itself:

| Rule | Weight | What it means |
| --- | --- | --- |
| `lookalike_domain` | 38 / 34 | Registrable domain within edit distance 1–2 of a known brand (38), or a domain that contains a brand name without being that brand, e.g. `paypal-secure-login.com` (34) |
| `userinfo_in_url` | 34 | `user@host` form, used to hide the real host |
| `brand_in_subdomain` | 34 | Brand name placed before the real domain (`paypal.login.example.tk`) |
| `digit_substitution` | 32 | Digits or homoglyphs substituted into a brand name (`paypa1`, `g00gle`) |
| `ip_host` | 30 | Numeric address instead of a website name |
| `executable_download` | 30 | Path ends in an installer or script (`.exe`, `.apk`, `.scr`, `.js`, …) |
| `punycode_host` | 28 | `xn--` host — a non-Latin alphabet imitating a familiar name |
| `open_redirect_param` | 22 | A parameter carrying another URL, used to bounce through a trusted host |
| `brand_in_path_only` | 20 | Brand name only in the path, on an unrelated domain |
| `url_shortener` | 18 | Known shortener, so the real destination is hidden |
| `credential_keywords` | 18 | `login`, `verify`, `secure`, `password`, `otp`, and similar in the path |
| `deep_subdomains` | 16 | Four or more labels before the registrable domain |
| `hyphen_heavy_domain` | 16 | Three or more hyphens in the domain |
| `suspicious_tld` | 14 | Top-level domain with a disproportionate share of abuse |
| `nonstandard_port` | 14 | An explicit, non-default port |
| `encoded_payload` | 14 | Long base64-like or heavily percent-encoded segment |
| `long_random_subdomain` | 14 | A long high-entropy label, typical of generated hosts |
| `no_tls` | 12 | Plain `http` |

Weights are combined with **diminishing returns**, not by addition: flags are
sorted by weight and each successive flag contributes 0.55 of what the previous
one did. Ten weak signals therefore cannot manufacture a high-risk verdict.

## 3. Model reading of the address

The canonical address, its host, and the flags that fired are sent to a language
model (`gpt-5.4-mini`) with a strict JSON schema, and it returns its own score,
a top-flag title and a one-line reason. The call has a 22-second timeout and
never throws: if it fails or times out, the verdict is produced from the
deterministic rules alone and `model_version` records that (`heuristics-only`).
The model **never** replaces the rules.

## 4. Combination

```
score = round(0.45 × rule_score + 0.55 × model_score)
```

then three guard rails, applied in this order:

1. Any single flag of weight ≥ 30 pins the score to at least **67** (high). A
   lookalike domain is not talked down by a confident model.
2. Two or more flags of weight ≥ 16 pin the score to at least **40** (medium).
3. An address whose registrable domain is a **known brand domain** with no
   strong flag is capped at **20**. This is what keeps everyday links out of the
   warning band.

Bands: **0–33 low**, **34–66 medium**, **67–100 high** — the same thresholds the
rest of the platform uses.

## 5. Exceptions and reuse

- An address on the exception list returns `low` with the reason recorded by the
  reviewer who added it. Exceptions expire after 180 days by default; an
  exception nobody renews stops being one.
- A verdict for the same hash is reused while it is still inside its one-hour
  TTL, and is marked `cached: true`. Whitelisting an address expires every cached
  verdict for it immediately, so a live exception is never shadowed.

## 6. Output contract

Every verdict carries: one score, one level, one action line, a `why` of at most
ten words, and two lines of explanation whose **second line is a concrete next
step** — call the number on your card, open the company's own app, type the
address yourself. The step is chosen by level, not generated freely, so it cannot
drift into advice we did not write.

Every verdict is signed with PS256, and the payload includes the score, the level,
the URL hash, the model version and both timestamps. A partner can verify it
without asking us anything.

## 7. What this does not measure

- **Safe-Link has no accuracy benchmark of its own yet.** The platform's published
  benchmark measures *content* analysis (messages, emails, images, audio) on a
  labelled set; it is not a link-detection figure and must not be quoted as one.
  Building a labelled URL set is the first measurement task, and until it exists
  no precision, recall or false-positive number should be attached to Safe-Link.
- The service reads the address, not the destination. A clean address on a site
  compromised later reads clean.
- A brand-new malicious domain with an unremarkable spelling and no other signal
  is the hardest case here, and the honest expectation is that some are missed.
- Shorteners are flagged but not expanded: expanding them would mean fetching the
  destination, which is exactly what we promise not to do.

This automated assessment is informational only. Not forensic evidence.
