Files
hyprcrd/public/plugins/email/SpamDetector.php
2026-04-10 10:42:18 +02:00

333 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* SpamDetector — multi-signal heuristic spam scoring.
*
* Composes signals from:
* - Upstream filter results (X-Spam-Flag, X-Spam-Status, X-Spam-Score)
* - Authentication-Results / Received-SPF (SPF/DKIM/DMARC pass/fail)
* - Subject heuristics (ALL CAPS, excess punctuation, spam keywords EN+DE)
* - Sender heuristics (suspicious TLDs, digit-heavy domains, lookalikes,
* display name mismatch, missing/mismatched Reply-To)
* - Optional body heuristics (link density, short URLs, currency spam)
*
* Scoring:
* 0–3 → clean (no marking)
* 4–7 → suspicious (yellow)
* 8+ → spam (red)
*/
class SpamDetector
{
/** User-defined block rules: sender emails (lowercase). */
private array $blockEmails = [];
/** User-defined block rules: sender domains (lowercase). */
private array $blockDomains = [];
/** User-defined allow rules. */
private array $allowEmails = [];
private array $allowDomains = [];
/**
* @param array $rules Optional array of rule rows from email_spam_rules:
* [{rule_type, sender_email, sender_domain}, ...]
*/
public function __construct(array $rules = [])
{
foreach ($rules as $r) {
$type = $r['rule_type'] ?? '';
$email = strtolower(trim((string)($r['sender_email'] ?? '')));
$domain = strtolower(trim((string)($r['sender_domain'] ?? '')));
if ($type === 'block') {
if ($email) $this->blockEmails[] = $email;
if ($domain) $this->blockDomains[] = $domain;
} elseif ($type === 'allow') {
if ($email) $this->allowEmails[] = $email;
if ($domain) $this->allowDomains[] = $domain;
}
}
}
/** English + German spam keywords. Each match adds 2 points (capped). */
private const SPAM_PHRASES = [
// English
'viagra', 'cialis', 'pharmacy', 'rolex', 'casino', 'lottery', 'winner',
'congratulations', 'claim your prize', 'act now', 'click here', 'free gift',
'limited time', 'risk free', 'no obligation', 'satisfaction guaranteed',
'money back', 'lose weight', 'work from home', 'make money fast',
'earn extra cash', 'inheritance', 'beneficiary', 'next of kin',
'crypto millionaire', '100% free', '100% satisfied', 'urgent reply',
'wire transfer', 'western union', 'nigerian prince', 'unclaimed funds',
'verify your account', 'suspended account', 'confirm your identity',
// German
'sie haben gewonnen', 'gewinnspiel', 'lotto', 'erbschaft', 'sofort gewinn',
'kostenlos', 'gratis', 'nur heute', 'einmalig', 'jetzt zugreifen',
'jetzt bestellen', 'risikofrei', 'geld verdienen', 'schnell reich',
'abnehmen ohne', 'wundermittel', 'klicken sie hier', 'bestätigen sie',
'ihr konto wurde', 'ihr passwort', 'verifizieren sie', 'dringend antworten',
];
/** Suspicious TLDs frequently used by spammers. */
private const SUSPICIOUS_TLDS = [
'top', 'xyz', 'click', 'link', 'work', 'gq', 'ml', 'tk', 'ga', 'cf',
'loan', 'racing', 'win', 'download', 'review', 'country', 'kim',
'science', 'party', 'bid', 'trade', 'date', 'stream', 'cricket',
'webcam', 'mom', 'rest',
];
/** URL shorteners (high spam correlation in cold email). */
private const SHORT_URL_HOSTS = [
'bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly', 'is.gd', 'buff.ly',
'tiny.cc', 'cutt.ly', 'shorturl.at', 'rebrand.ly', 'rb.gy',
];
/**
* @param array $headers Lowercase-keyed header map (as returned by ImapClient::parseHeaders)
* @param string|null $bodyText Optional plaintext body for content checks
* @return array{score:int, level:string, reasons:array<string>}
*/
public function score(array $headers, ?string $bodyText = null): array
{
$score = 0;
$reasons = [];
// ── User-defined rules (override the heuristic with strong weight) ──
$fromAddrEarly = $this->extractAddress($headers['from'] ?? '');
$fromDomainEarly = strtolower($this->extractDomain($fromAddrEarly));
$fromAddrLow = strtolower($fromAddrEarly);
if ($fromAddrLow && in_array($fromAddrLow, $this->allowEmails, true)) {
$score -= 15;
$reasons[] = 'sender on allowlist (email)';
} elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->allowDomains, true)) {
$score -= 12;
$reasons[] = 'sender domain on allowlist';
}
if ($fromAddrLow && in_array($fromAddrLow, $this->blockEmails, true)) {
$score += 15;
$reasons[] = 'sender on blocklist (email)';
} elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->blockDomains, true)) {
$score += 12;
$reasons[] = 'sender domain on blocklist';
}
// ── Upstream filter verdict (strongest signal) ──────────────
$xSpamFlag = strtolower(trim($headers['x-spam-flag'] ?? ''));
if ($xSpamFlag === 'yes') {
$score += 10;
$reasons[] = 'upstream X-Spam-Flag: YES';
}
$xSpamStatus = $headers['x-spam-status'] ?? '';
if (preg_match('/^Yes/i', $xSpamStatus)) {
$score += 8;
$reasons[] = 'upstream X-Spam-Status: Yes';
}
if (preg_match('/score\s*=\s*([\d.]+)/i', $xSpamStatus, $m)) {
$sa = (float)$m[1];
if ($sa >= 5) {
$score += 5;
$reasons[] = "SpamAssassin score $sa";
} elseif ($sa >= 3) {
$score += 2;
$reasons[] = "SpamAssassin score $sa";
}
}
$xSpamScore = $headers['x-spam-score'] ?? '';
if (is_numeric(trim($xSpamScore)) && (float)$xSpamScore >= 5) {
$score += 4;
$reasons[] = "X-Spam-Score $xSpamScore";
}
// ── Authentication results (SPF/DKIM/DMARC) ─────────────────
$authResults = strtolower($headers['authentication-results'] ?? '');
$receivedSpf = strtolower($headers['received-spf'] ?? '');
if (str_contains($authResults, 'spf=fail') || str_starts_with($receivedSpf, 'fail')) {
$score += 4;
$reasons[] = 'SPF fail';
} elseif (str_contains($authResults, 'spf=softfail') || str_starts_with($receivedSpf, 'softfail')) {
$score += 1;
$reasons[] = 'SPF softfail';
}
if (str_contains($authResults, 'dkim=fail')) {
$score += 3;
$reasons[] = 'DKIM fail';
}
if (str_contains($authResults, 'dmarc=fail')) {
$score += 4;
$reasons[] = 'DMARC fail';
}
// Missing Message-ID is rare in legitimate mail
if (empty($headers['message-id']) && empty($headers['x-spam-flag'])) {
$score += 2;
$reasons[] = 'missing Message-ID';
}
// ── Subject heuristics ──────────────────────────────────────
$subject = $headers['subject'] ?? '';
if ($subject !== '') {
// ALL CAPS check (only count letters; ignore short subjects)
$letters = preg_replace('/[^A-Za-zÄÖÜäöüß]/u', '', $subject);
if (strlen($letters) >= 12) {
$upper = preg_replace('/[^A-ZÄÖÜ]/u', '', $letters);
$ratio = strlen($upper) / max(1, strlen($letters));
if ($ratio > 0.7) {
$score += 3;
$reasons[] = 'ALL CAPS subject';
}
}
// Excessive exclamation/question marks
$excessPunct = preg_match_all('/[!?]{2,}/', $subject);
if ($excessPunct >= 1) {
$score += min(3, $excessPunct + 1);
$reasons[] = 'excessive punctuation';
}
// Currency clusters
if (preg_match_all('/[\$€£¥]\s*\d+|\d+\s*[\$€£¥]|EUR\s*\d+|USD\s*\d+/i', $subject, $cm)) {
if (count($cm[0]) >= 1) {
$score += 2;
$reasons[] = 'currency in subject';
}
}
// Keyword matches (capped at +6)
$kwScore = 0;
$kwHits = 0;
$lowSubject = function_exists('mb_strtolower') ? mb_strtolower($subject) : strtolower($subject);
foreach (self::SPAM_PHRASES as $phrase) {
if (str_contains($lowSubject, $phrase)) {
$kwScore += 2;
$kwHits++;
if ($kwHits >= 3) break; // cap at 3 distinct hits
}
}
if ($kwScore > 0) {
$score += min(6, $kwScore);
$reasons[] = "$kwHits spam keyword" . ($kwHits === 1 ? '' : 's');
}
// Cyrillic homoglyph attack: Latin recipients getting Cyrillic letters
if (preg_match('/[\x{0400}-\x{04FF}]/u', $subject)
&& preg_match('/[a-zA-Z]/', $subject)) {
$score += 4;
$reasons[] = 'mixed Latin/Cyrillic (homoglyph)';
}
}
// ── Sender heuristics ───────────────────────────────────────
$from = $headers['from'] ?? '';
$replyTo = $headers['reply-to'] ?? '';
$fromAddr = $this->extractAddress($from);
$replyAddr = $this->extractAddress($replyTo);
$fromDomain = $this->extractDomain($fromAddr);
$replyDomain = $this->extractDomain($replyAddr);
// Reply-To pointing to a different domain than From (classic phishing)
if ($replyDomain && $fromDomain && strcasecmp($replyDomain, $fromDomain) !== 0) {
$score += 3;
$reasons[] = "Reply-To domain mismatch ($replyDomain ≠ $fromDomain)";
}
// Suspicious TLD
if ($fromDomain) {
$tld = strtolower((string)substr(strrchr($fromDomain, '.'), 1));
if (in_array($tld, self::SUSPICIOUS_TLDS, true)) {
$score += 3;
$reasons[] = "suspicious TLD .$tld";
}
// Digit-heavy domain (often throwaways like x4892jk-shop.com)
$digitCount = preg_match_all('/\d/', $fromDomain);
$domainLen = strlen($fromDomain);
if ($domainLen > 8 && $digitCount / $domainLen > 0.3) {
$score += 2;
$reasons[] = 'digit-heavy sender domain';
}
// Hyphen overload
if (substr_count($fromDomain, '-') >= 3) {
$score += 1;
$reasons[] = 'many hyphens in domain';
}
}
// Display name impersonates an email address that doesn't match the actual address
if (preg_match('/^"?([^"<]+)"?\s*<([^>]+)>$/', $from, $fm)) {
$displayName = trim($fm[1]);
$actualAddr = trim($fm[2]);
if (preg_match('/[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $displayName, $dm)) {
if (strcasecmp($dm[0], $actualAddr) !== 0) {
$score += 4;
$reasons[] = 'display name impersonates a different email';
}
}
}
// ── List-Unsubscribe present is a slight signal it's bulk
// (not necessarily spam, just a small positive bump)
if (!empty($headers['list-unsubscribe'])) {
$score += 1;
$reasons[] = 'bulk mail (list-unsubscribe)';
}
// ── Body heuristics (only if body is provided) ──────────────
if ($bodyText) {
// Count URLs
preg_match_all('#https?://([^\s<>"\']+)#i', $bodyText, $urlMatches);
$urls = $urlMatches[1] ?? [];
$urlCount = count($urls);
if ($urlCount > 8) {
$score += min(4, intdiv($urlCount, 4));
$reasons[] = "$urlCount links in body";
}
$shortCount = 0;
foreach ($urls as $u) {
$host = strtolower(parse_url('http://' . $u, PHP_URL_HOST) ?? '');
if ($host && in_array($host, self::SHORT_URL_HOSTS, true)) $shortCount++;
}
if ($shortCount >= 2) {
$score += 2;
$reasons[] = "$shortCount short URLs";
}
}
// ── Determine level ─────────────────────────────────────────
if ($score >= 8) {
$level = 'spam';
} elseif ($score >= 4) {
$level = 'suspicious';
} else {
$level = 'clean';
}
return ['score' => $score, 'level' => $level, 'reasons' => $reasons];
}
private function extractAddress(string $field): string
{
if (preg_match('/<([^>]+)>/', $field, $m)) return trim($m[1]);
if (preg_match('/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $field, $m)) return $m[0];
return '';
}
private function extractDomain(string $address): string
{
$at = strrpos($address, '@');
return $at === false ? '' : strtolower(substr($address, $at + 1));
}
}