Package score

Deterministic risk scoring with a relationship-gated five-way verdict: contextual domain-age tiers, certificate downgrade (+40), registrar mismatch (+30), DNS resolution tampering (+50), rogue-redirection signals (per-path TLS divergence +50, origin-AS split +30, hosts-file override +50), and display-only sender-authority/chronology context. Sources: internal/score/score.go, internal/score/date.go. Max total is 330.


DomainData

type DomainData struct {
    Domain    string // queried domain
    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
    SysIP       string // first system-resolved A record, "" when none
    PubIP       string // first public-resolved A record, "" when none
    PathChecked  bool   // both paths resolved to an IP
    PathComplete bool   // both per-path TLS identities fetched
    PathMatch    bool   // per-path TLS identities agree
    PathDetail   string // mismatch description
    SysASN       string // origin AS via local path, e.g. "AS15169"
    PubASN       string // origin AS via public path
    SysASNNet    string // prefix/country detail, e.g. "8.8.8.0/24, US"
    PubASNNet    string
    ASNChecked   bool   // both paths attributed
    ASNMatch     bool   // both paths share an origin AS
    HostsHit     bool   // static hosts-file mapping exists
    HostsIP      string // the mapped IP
    SPFChecked    bool   // target SPF was retrieved and evaluated
    SPFAuthorized bool   // suspect IP is covered by target SPF
    SPFCover      string // covering mechanism, e.g. "include:_spf.google.com"
    DMARCPolicy   string // target DMARC p= value, "" when unpublished
}

Collected triage fields for one domain; the scoring input. Raw addresses are retained for per-path interrogation only and are never compared for points.


Result

type Result struct {
    Score        int
    Findings     []string
    Verdict      string
    AgeFlag      string // display suffix for young suspect domains
    Relationship string // identical | san_endorsed | spf_endorsed | lookalike | unrelated
}

Scoring outcome. Relationship names the target/suspect relationship (see Score); it gates the LEGITIMATE verdict.


ClassLong

func ClassLong(c string) string

Human-readable validation name via lookup table: EV to "Extended Validation", OV to "Organization Validated", DV to "Domain Validated", IV to "Individual Validated", anything else to "Unknown".


VerifyDataQuality

func VerifyDataQuality(target, suspect DomainData) error

Fails closed: returns an error when either domain lacks a valid RDAP date, a computable age, or any TLS signal (issuer and class both unknown). The CLI aborts instead of printing a verdict built on missing data.


Score

func Score(target, suspect DomainData) Result

Applies the signals. Identical domains force score zero and LEGITIMATE while still attaching DNS NOTEs. Suspect age is contextual: the full tier (< 30d: 80, 30-89d: 40) needs company (registrar mismatch or cert downgrade), otherwise the next tier down; endorsed infrastructure (shared certificate SANs or target SPF coverage) halves whatever remains. Target EV/OV versus suspect DV adds 40 as DOWNGRADE; a same-class issuer difference is a display-only NOTE (+0). Case-insensitive registrar mismatch adds 30, skipped when either side is unknown; a shared known registrar is a display-only NOTE. Target DMARC policy is a display-only NOTE. Suspect-side DNS suppression and NS splits add 50; split-horizon shapes and anything target-side are display-only NOTEs (+0). Rogue redirection on the suspect side: divergent per-path TLS identities add 50, split origin AS adds 30, a hosts-file override adds 50 (target-side and inconclusive probes stay display-only). A suspect far older than a very young target (under 90d vs over a year) adds a display-only CHRONOLOGY NOTE — the pair was likely fed backwards. Every pair is classified (identical, SAN/SPF-endorsed, lookalike via confusable-aware label similarity, or unrelated): LEGITIMATE requires a relationship, so unrelated pairs scoring 0-39 read UNRELATED with a zero-trust NO AUTHORITY finding instead. SUSPICIOUS and PHISHING bands keep their verdicts. The 15-39 band appends an EXPLANATION finding (malicious-infrastructure vs business-incompetence readings).

r := score.Score(target, suspect)
// r.Score, r.Findings, r.Verdict, r.Relationship, r.AgeFlag

Verdict

func Verdict(score int) string

Maps the total via threshold table: 51+ phishing, 40+ suspicious, 15+ malicious-or-negligent (related pairs), otherwise legitimate (related pairs). Unrelated pairs scoring 0-39 map to the UNRELATED verdict instead — see Score. Verdict strings live in named constants (VerdictPhishing, VerdictSuspicious, VerdictMaliciousOrNegligent, VerdictLegitimate, VerdictUnrelated); relationships in RelIdentical, RelSANEndorsed, RelSPFEndorsed, RelLookalike, RelUnrelated. Max total is 330.


ValidISODate

func ValidISODate(s string) bool

True only for clean, calendar-valid YYYY-MM-DD strings. Any sentinel or garbage value fails by design.


AgeDays

func AgeDays(ymd string, now time.Time) (days int, ok bool)

Whole days between ymd and now; ok is false when ymd is unparseable.


Licensed under the GNU General Public License v3 - Mark Robillard Jr (MARKMENTAL)