phishguard-api / screenshot_hasher.py
prashanth135's picture
Upload 38 files
bebe233 verified
Raw
History Blame Contribute Delete
7.16 kB
# ============================================================
# PhishGuard AI - cnn/screenshot_hasher.py
# Perceptual hash-based brand impersonation detector.
#
# Compares webpage screenshots against reference hashes of
# known brand login pages using imagehash.phash.
#
# brand_boost = 0.25 if hamming_distance < 10 else 0.0
# ============================================================
from __future__ import annotations
import io
import json
import logging
from pathlib import Path
from typing import Tuple, Optional, Dict, List
from PIL import Image
logger = logging.getLogger("phishguard.cnn.hasher")
# ── Try to use imagehash, fall back to custom implementation ─────────
_imagehash_available = False
try:
import imagehash
_imagehash_available = True
except ImportError:
logger.info("imagehash not installed β€” using built-in phash")
HASH_DB_PATH = Path(__file__).parent / "brand_hashes.json"
class BrandHashDetector:
"""
Perceptual hash-based brand impersonation detector.
Compares screenshots against reference hashes of 10 major brands.
"""
BRANDS: List[str] = [
"paypal", "google", "apple", "microsoft", "amazon",
"chase", "netflix", "facebook", "instagram", "wellsfargo",
]
BRAND_DOMAINS: Dict[str, str] = {
"paypal": "paypal.com",
"google": "google.com",
"apple": "apple.com",
"microsoft": "microsoft.com",
"amazon": "amazon.com",
"chase": "chase.com",
"netflix": "netflix.com",
"facebook": "facebook.com",
"instagram": "instagram.com",
"wellsfargo": "wellsfargo.com",
}
def __init__(self, hash_db_path: Optional[Path] = None) -> None:
self._hash_db_path = hash_db_path or HASH_DB_PATH
self._reference_hashes: Dict[str, dict] = {}
self._load_reference_hashes()
def _load_reference_hashes(self) -> None:
"""Load reference hashes from JSON database."""
if self._hash_db_path.exists():
try:
with open(self._hash_db_path) as f:
self._reference_hashes = json.load(f)
logger.info(f"Loaded {len(self._reference_hashes)} brand hashes")
except Exception as e:
logger.warning(f"Failed to load brand hashes: {e}")
self._reference_hashes = {}
else:
logger.info("No brand hash DB found β€” brand detection disabled")
self._reference_hashes = {}
def compute_hash(self, img_bytes: bytes, hash_size: int = 16) -> Optional[int]:
"""
Compute perceptual hash of an image.
Uses imagehash.phash if available, otherwise custom DCT-less implementation.
"""
try:
img = Image.open(io.BytesIO(img_bytes))
if _imagehash_available:
h = imagehash.phash(img, hash_size=hash_size)
return int(str(h), 16)
else:
return self._custom_phash(img, hash_size)
except Exception as e:
logger.warning(f"Hash computation failed: {e}")
return None
def _custom_phash(self, img: Image.Image, hash_size: int = 16) -> int:
"""Fallback perceptual hash (mean-based, no DCT)."""
img = img.convert("L").resize((hash_size, hash_size), Image.LANCZOS)
pixels = list(img.getdata())
avg = sum(pixels) / len(pixels)
bits = "".join("1" if p > avg else "0" for p in pixels)
return int(bits, 2)
def hamming_distance(self, h1: int, h2: int) -> int:
"""Count bit differences between two hashes. 0 = identical."""
return bin(h1 ^ h2).count("1")
def detect(
self,
screenshot_bytes: bytes,
url: str = "",
threshold: int = 10,
) -> Tuple[bool, str, float]:
"""
Detect brand impersonation from screenshot.
Returns:
(is_impersonation, brand_name, confidence)
is_impersonation: True if page looks like a brand but URL doesn't match
brand_name: detected brand name or ""
confidence: 0.0-1.0 similarity score
"""
page_hash = self.compute_hash(screenshot_bytes)
if page_hash is None:
return False, "", 0.0
url_lower = url.lower()
best_match: Optional[str] = None
best_distance = 999
best_confidence = 0.0
for brand, entry in self._reference_hashes.items():
try:
stored_hash = int(entry["hash"])
distance = self.hamming_distance(page_hash, stored_hash)
confidence = max(0.0, 1.0 - distance / 256.0)
if distance < best_distance:
best_distance = distance
best_match = brand
best_confidence = confidence
except (ValueError, KeyError):
continue
if best_match and best_distance <= threshold:
legit_domain = self.BRAND_DOMAINS.get(best_match, f"{best_match}.com")
# Check if URL belongs to legitimate domain
if legit_domain not in url_lower:
return True, best_match, best_confidence
else:
return False, best_match, best_confidence
return False, "", 0.0
def register_brand(
self,
brand_name: str,
domain: str,
screenshot_bytes: bytes,
) -> bool:
"""Register a brand's reference screenshot hash."""
h = self.compute_hash(screenshot_bytes)
if h is None:
return False
self._reference_hashes[brand_name] = {
"domain": domain,
"hash": str(h),
}
# Save to disk
try:
with open(self._hash_db_path, "w") as f:
json.dump(self._reference_hashes, f, indent=2)
logger.info(f"Registered brand: {brand_name} ({domain})")
return True
except Exception as e:
logger.error(f"Failed to save brand hash: {e}")
return False
# ── Legacy compatibility ─────────────────────────────────────────────
_detector = BrandHashDetector()
def check_brand_impersonation(
screenshot_bytes: bytes,
url: str,
similarity_threshold: int = 10,
) -> dict:
"""Legacy wrapper for backward compatibility."""
is_impersonation, brand, confidence = _detector.detect(
screenshot_bytes, url, similarity_threshold,
)
if is_impersonation:
return {
"impersonation_detected": True,
"impersonated_brand": brand,
"legitimate_domain": _detector.BRAND_DOMAINS.get(brand, ""),
"visual_similarity": round(confidence, 3),
}
elif brand:
return {
"impersonation_detected": False,
"matched_brand": brand,
"note": "legitimate site",
}
else:
return {
"impersonation_detected": False,
"reason": "no_brand_match",
}