Skip to main content
Every URL scan produces a numeric risk score between 0 and 100. The score is the sum of all checker contributions, capped at 100. A verdict is then derived from the score.

Score aggregation

// src/Scanner.ts
const totalScore = Math.min(
  100,
  checks.reduce((a, c) => a + c.score, 0)
);
Each checker returns a CheckResult:
// src/types.ts
export interface CheckResult {
    score: number;
    reason?: string;
    reasons?: string[];
}
All score values are summed and capped at 100. A checker that times out or errors always returns { score: 0 }.

Verdict thresholds

// src/Scanner.ts
const verdict =
  totalScore >= 70 ? "phishing" : totalScore >= 40 ? "suspicious" : "safe";
Score rangeVerdict
0 – 39safe
40 – 69suspicious
70 – 100phishing

Full scan result shape

// src/types.ts
export interface ScanResult {
    url: string;
    score: number;
    verdict: "phishing" | "suspicious" | "safe";
    reasons: string[];
    executionTimeMs?: Record<string, number>;
}
reasons is the union of all reason and reasons fields from every checker. executionTimeMs is a map of checker name to wall-clock milliseconds.

Checker score contributions

CheckerConditionScore contributed
heuristicsURL length > 200 characters+10
heuristicsURL contains @+20
heuristicsEach suspicious keyword matched+7 per keyword
heuristicsHyphens in registered domain+6
heuristicsProtocol is not https:+10
heuristicsDNS resolution fails or returns private IP+25
heuristicsDomain age < 90 days+10
heuristicsDomain age 90–364 days+4
heuristicsDomain age ≥ 365 days−2
heuristicsPrivate/internal IP address50 (returned immediately)
openphishExact URL match100
openphishHostname match80
google_safe_browsingAny threat match returned by API50
urlhausExact URL match100
phishtankExact URL match100
phishstatsExact URL match100
phishstatsHostname match80
Because scores are additive across checkers before the cap is applied, a URL that triggers multiple heuristic signals and also matches a threat feed can easily reach 100 from multiple lower-scoring sources alone.

Heuristics checker in detail

The heuristics checker is the only checker that does not rely on an external feed. It inspects the URL structure, performs DNS resolution, and queries WHOIS data.

Suspicious keywords

The following keywords are checked against the lowercased URL:
// src/checkers/heuristics.ts
const sus = ["verify", "update", "secure", "login", "support", "account"];
const count = sus.filter((x) => url.toLowerCase().includes(x)).length;
if (count > 0) {
  score += count * 7;
  reasons.push("Contains suspicious keywords");
}
Each matched keyword adds 7 points. A URL containing all six keywords would add 42 points before any other signal is evaluated.

WHOIS domain age

The heuristics checker fetches WHOIS data for the registered domain and scores based on how recently the domain was created:
// src/checkers/heuristics.ts
const ageDays = Math.floor(
  (Date.now() - cd.getTime()) / (1000 * 60 * 60 * 24)
);
if (ageDays < 90) {
  scoreDelta += 10;
  reasons.push("Domain is recently created (<90 days)");
} else if (ageDays < 365) {
  scoreDelta += 4;
} else {
  scoreDelta -= 2;
}
Domain ageScore delta
< 90 days+10 (reason added)
90 – 364 days+4
≥ 365 days−2
WHOIS results are cached in Redis for 24 hours (whois_data hash + whois_expiry ZSET) to avoid redundant lookups.

DNS failure

The heuristics checker calls safeResolveHost() during scoring. If DNS resolution fails — either because the host does not exist or because it resolves to a private IP range — the score increases by 25:
// src/checkers/heuristics.ts
try {
  await safeResolveHost(hostname);
} catch {
  score += 25;
  reasons.push("DNS failed or private network");
}

Score floor

The heuristics checker applies a floor of 0 before returning, preventing negative scores from the domain-age bonus from propagating:
score = Math.max(0, score);

Build docs developers (and LLMs) love