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 range | Verdict |
|---|
| 0 – 39 | safe |
| 40 – 69 | suspicious |
| 70 – 100 | phishing |
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
| Checker | Condition | Score contributed |
|---|
heuristics | URL length > 200 characters | +10 |
heuristics | URL contains @ | +20 |
heuristics | Each suspicious keyword matched | +7 per keyword |
heuristics | Hyphens in registered domain | +6 |
heuristics | Protocol is not https: | +10 |
heuristics | DNS resolution fails or returns private IP | +25 |
heuristics | Domain age < 90 days | +10 |
heuristics | Domain age 90–364 days | +4 |
heuristics | Domain age ≥ 365 days | −2 |
heuristics | Private/internal IP address | 50 (returned immediately) |
openphish | Exact URL match | 100 |
openphish | Hostname match | 80 |
google_safe_browsing | Any threat match returned by API | 50 |
urlhaus | Exact URL match | 100 |
phishtank | Exact URL match | 100 |
phishstats | Exact URL match | 100 |
phishstats | Hostname match | 80 |
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 age | Score 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);