phisherslapper/internal/score/score.go

265 lines
9.8 KiB
Go

// Copyright (C) 2026 Mark Robillard Jr (MARKMENTAL)
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License v3. See LICENSE.
// SPDX-License-Identifier: GPL-3.0-only
// Package score implements the deterministic risk scoring logic.
// Age tiers (<30 CRITICAL, 30-89 WARNING) are contextual: full weight
// needs company (registrar mismatch or cert downgrade), shared certificate
// infrastructure halves the tier, and identical domains score zero.
// Downgrade (EV/OV -> DV) is +40, registrar mismatch +30 case-insensitive.
package score
import "fmt"
import "strings"
import "isthislegit/internal/dns"
// DomainData holds the collected triage fields for one domain.
type DomainData struct {
Domain string
Date string // YYYY-MM-DD or sentinel on failure
Registrar string // "Unknown" when unavailable
Issuer string // "Unknown" when unavailable
Class string // EV, OV, IV, DV, or Unknown
SANs string // comma-joined, or "Unknown"
AgeDays int
AgeKnown bool
DNSTampered bool // suppression/NS split (scores on suspect)
DNSSplit dns.SplitKind // split direction (horizon never scores)
DNSDetail string // human-readable split description
}
// Result is the outcome of scoring a target/suspect pair.
type Result struct {
Score int
Findings []string
Verdict string
AgeFlag string
}
// classLongNames maps validation classes to human-readable names.
var classLongNames = map[string]string{
"EV": "Extended Validation",
"OV": "Organization Validated",
"DV": "Domain Validated",
"IV": "Individual Validated",
}
// ClassLong returns the human-readable validation name.
func ClassLong(c string) string {
if name, ok := classLongNames[c]; ok {
return name
}
return "Unknown"
}
// VerifyDataQuality fails closed when RDAP dates or TLS data are missing
// for either domain. Mirrors bash verify_data_quality.
func VerifyDataQuality(target, suspect DomainData) error {
if !ValidISODate(target.Date) {
return fmt.Errorf("RDAP lookup failed for target '%s' (got '%s'). Please check connectivity and try again", target.Domain, target.Date)
}
if !ValidISODate(suspect.Date) {
return fmt.Errorf("RDAP lookup failed for suspect '%s' (got '%s'). Please check connectivity and try again", suspect.Domain, suspect.Date)
}
if !target.AgeKnown {
return fmt.Errorf("could not determine age of target '%s' (got '%s'). Please check connectivity and try again", target.Domain, target.Date)
}
if !suspect.AgeKnown {
return fmt.Errorf("could not determine age of suspect '%s' (got '%s'). Please check connectivity and try again", suspect.Domain, suspect.Date)
}
if (target.Issuer == "" || target.Issuer == "Unknown") && (target.Class == "" || target.Class == "Unknown") {
return fmt.Errorf("TLS certificate fetch failed for target '%s' (port 443 unreachable or handshake failed). Please check connectivity and try again", target.Domain)
}
if (suspect.Issuer == "" || suspect.Issuer == "Unknown") && (suspect.Class == "" || suspect.Class == "Unknown") {
return fmt.Errorf("TLS certificate fetch failed for suspect '%s' (port 443 unreachable or handshake failed). Please check connectivity and try again", suspect.Domain)
}
return nil
}
// Score applies the signals and maps the total to a verdict.
//
// Age is contextual, not a binary cliff: identical domains force zero
// with findings attached; shared certificate infrastructure halves the
// tier; full tier needs company (registrar mismatch or cert downgrade),
// with isolated domains scoring the next tier down.
func Score(target, suspect DomainData) Result {
var r Result
if target.Domain != "" && strings.EqualFold(target.Domain, suspect.Domain) {
r.Findings = append(r.Findings, "INFO: Target and suspect are identical — a domain cannot impersonate itself.")
noteDNS(&r, target, "target")
if suspect.DNSDetail != target.DNSDetail {
noteDNS(&r, suspect, "suspect")
}
r.Verdict = Verdict(r.Score)
return r
}
// Cert validation downgrade: target EV/OV but suspect DV => +40.
// Same-class issuer differences are display-only (no points).
isDowngrade := (target.Class == "EV" || target.Class == "OV") && suspect.Class == "DV"
mismatch := registrarMismatch(target, suspect)
stacked := mismatch || isDowngrade
sharedCert := sansOverlap(target, suspect)
// Age check with stacking and shared-infrastructure discount.
if suspect.AgeKnown {
switch {
case suspect.AgeDays < 30:
r.AgeFlag = " [CRITICAL: Domain created < 30 days ago]"
r.Score += agePoints(80, stacked, sharedCert)
r.Findings = append(r.Findings, ageFinding(suspect.Date, 80, stacked, sharedCert))
case suspect.AgeDays < 90:
r.AgeFlag = " [WARNING: Domain created < 90 days ago]"
r.Score += agePoints(40, stacked, sharedCert)
r.Findings = append(r.Findings, ageFinding(suspect.Date, 40, stacked, sharedCert))
}
}
if isDowngrade {
r.Score += 40
r.Findings = append(r.Findings, fmt.Sprintf("DOWNGRADE: Target uses %s (%s) while Suspect uses DV automated cert (%s).", target.Class, target.Issuer, suspect.Issuer))
}
if !isDowngrade && isIssuerNote(target, suspect) {
r.Findings = append(r.Findings, fmt.Sprintf("NOTE: Certificate Issuers differ (%s vs %s) but validation levels are comparable (%s vs %s).", target.Issuer, suspect.Issuer, target.Class, suspect.Class))
}
// Registrar mismatch => +30 (skipped when either side is unknown).
if mismatch {
r.Score += 30
r.Findings = append(r.Findings, fmt.Sprintf("MISMATCH: Registrars do not align (%s vs %s).", target.Registrar, suspect.Registrar))
}
// Resolution tampering => +50 on the suspect side for suppression and
// NS splits. Split-horizon shapes describe split-horizon or observer
// networks, never the suspect, so they stay display-only — as does
// anything on the target side.
if suspect.DNSTampered {
r.Score += 50
r.Findings = append(r.Findings, fmt.Sprintf("TAMPERING: Inconsistent DNS resolution for suspect (%s).", suspect.DNSDetail))
}
if suspect.DNSSplit == dns.SplitHorizon {
r.Findings = append(r.Findings, fmt.Sprintf("NOTE: Split-horizon DNS for suspect (%s); resolves locally only.", suspect.DNSDetail))
}
noteDNS(&r, target, "target")
r.Verdict = Verdict(r.Score)
return r
}
// noteDNS appends display-only DNS findings for one side: tampering notes
// observer-side suspicion, horizon notes split-horizon shape. No points.
func noteDNS(r *Result, d DomainData, role string) {
if d.DNSDetail == "" {
return
}
if d.DNSTampered {
r.Findings = append(r.Findings, fmt.Sprintf("NOTE: Inconsistent DNS resolution for %s (%s); observer-side network suspected.", role, d.DNSDetail))
return
}
if d.DNSSplit == dns.SplitHorizon {
r.Findings = append(r.Findings, fmt.Sprintf("NOTE: Split-horizon DNS for %s (%s); resolves locally only.", role, d.DNSDetail))
}
}
// registrarMismatch reports a case-insensitive registrar difference,
// skipped when either side is unknown.
func registrarMismatch(target, suspect DomainData) bool {
known := func(s string) bool { return s != "" && s != "Unknown" }
return known(target.Registrar) && known(suspect.Registrar) && !strings.EqualFold(target.Registrar, suspect.Registrar)
}
// agePoints resolves an age tier to points: the full tier needs company
// (stacked signals), otherwise the next tier down; shared certificate
// infrastructure halves whatever remains.
func agePoints(tier int, stacked, sharedCert bool) int {
points := tier
if !stacked {
points /= 2
}
if sharedCert {
points /= 2
}
return points
}
// ageFinding words the age finding by final points, with the reason the
// tier was reduced when it was.
func ageFinding(date string, tier int, stacked, sharedCert bool) string {
points := agePoints(tier, stacked, sharedCert)
level := "LOW RISK"
if points >= 80 {
level = "HIGH RISK"
}
if points >= 40 && points < 80 {
level = "ELEVATED RISK"
}
reason := ""
if sharedCert {
reason = " Shared certificate infrastructure with target."
}
if reason == "" && !stacked {
reason = " No corroborating registrar or certificate signals."
}
return fmt.Sprintf("%s: Suspect domain created recently (%s).%s", level, date, reason)
}
// sansOverlap reports shared certificate infrastructure: either domain
// appearing in the other's SAN set, exactly or via a parent wildcard.
// Unknown or empty SAN fields never match.
func sansOverlap(target, suspect DomainData) bool {
return sanCovers(suspect.SANs, target.Domain) || sanCovers(target.SANs, suspect.Domain)
}
// sanCovers reports whether a comma-joined SAN field covers domain.
func sanCovers(sansField, domain string) bool {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return false
}
for _, san := range strings.Split(sansField, ",") {
san = strings.ToLower(strings.TrimSpace(san))
if san == "" || san == "unknown" {
continue
}
if san == domain {
return true
}
if parent, ok := strings.CutPrefix(san, "*."); ok && parent != "" {
if domain == parent || strings.HasSuffix(domain, "."+parent) {
return true
}
}
}
return false
}
// isIssuerNote reports a display-only issuer difference: distinct known
// issuers at comparable validation levels. Avoids false positives when both
// sides legitimately use different DV CAs.
func isIssuerNote(target, suspect DomainData) bool {
known := func(s string) bool { return s != "" && s != "Unknown" }
return target.Issuer != suspect.Issuer && known(target.Issuer) && known(suspect.Issuer)
}
// verdictScale maps minimum scores onto verdicts, highest first (see --scale).
var verdictScale = []struct {
min int
verdict string
}{
{51, "LIKELY PHISHING / IMPERSONATION ATTEMPT"},
{40, "SUSPICIOUS — MANUAL REVIEW RECOMMENDED"},
{15, "LIKELY UNRELATED / MISCONFIGURED"},
}
// Verdict maps the total score onto a verdict (see --scale).
func Verdict(score int) string {
for _, v := range verdictScale {
if score >= v.min {
return v.verdict
}
}
return "LIKELY LEGITIMATE"
}