From 26de54e680ed3a8ae07f5e7af677c09146ff68fb Mon Sep 17 00:00:00 2001 From: markmental Date: Wed, 9 Sep 2026 15:13:20 -0400 Subject: [PATCH] Display-only CHRONOLOGY note when the suspect is far older than a young target (likely swapped arguments); syncs README and developer docs to v1.03 --- README.md | 7 +++ docs/cert.html | 42 +++++++++++--- docs/dns.html | 93 +++++++++++++++++++++++++++++-- docs/nav.html | 8 +++ docs/overview.html | 5 +- docs/report.html | 13 ++++- docs/score.html | 80 ++++++++++++++++++++------ internal/report/report.go | 2 + internal/score/chronology_test.go | 65 +++++++++++++++++++++ internal/score/score.go | 26 +++++++++ 10 files changed, 304 insertions(+), 37 deletions(-) create mode 100644 internal/score/chronology_test.go diff --git a/README.md b/README.md index f5b6dbf..cf1eb88 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ VERDICT: UNRELATED — NO AUTHORITY OVER TARGET | Rogue ASN: per-path AS split (suspect) | +30 | | Hosts-file override (suspect) | +50 | | Rogue signals target-side/inconclusive (info) | +0 | +| Chronology inversion: suspect far older than young target (info) | +0 | | Score | Verdict | |-------|----------------------------------------| @@ -262,6 +263,12 @@ impossible for a real business): incompetence so severe no legitimate operator this broken survives long enough to do business with you. Either way, do not trust the domain. +A suspect far older than a very young target (under 90 days vs over a +year) trips a display-only `CHRONOLOGY` finding instead of points: a +days-old "brand" has nothing to protect, so the pair was likely fed +backwards and the "target" is the imposter. Re-run with the established +brand as the target. + Lookups fail closed: if RDAP dates or TLS data cannot be retrieved for either domain, the tool errors out instead of printing a verdict built on missing data, and the message names the underlying cause(s) (e.g. a TLS diff --git a/docs/cert.html b/docs/cert.html index 04097bb..d3f0f41 100644 --- a/docs/cert.html +++ b/docs/cert.html @@ -15,15 +15,16 @@ classification from policy OIDs. Source: internal/cert/cert.go.

Details

type Details struct {
-    Issuer    string    // Organization, or fallback issuer string
-    Class     string    // EV, OV, IV, DV, or Unknown
-    SANs      []string  // DNS SANs (CN fallback), empty when unavailable
-    OIDs      []string  // raw policy OIDs found
-    Subject   string
-    IssuerStr string
-    NotBefore time.Time
-    NotAfter  time.Time
-    HasCert   bool
+    Issuer      string    // Organization, or fallback issuer string
+    Class       string    // EV, OV, IV, DV, or Unknown
+    SANs        []string  // DNS SANs (CN fallback), empty when unavailable
+    OIDs        []string  // raw policy OIDs found
+    Subject     string
+    IssuerStr   string
+    Fingerprint string    // SHA-256 of the raw leaf, hex-encoded
+    NotBefore   time.Time
+    NotAfter    time.Time
+    HasCert     bool
 }

Extracted leaf-certificate fields. Issuer prefers the Organization attribute and falls back to Common Name, then the full @@ -52,6 +53,29 @@ Transient failures are retried up to 3 times with a 1 second delay.

perCallPer-attempt deadline (5 seconds in the CLI). +
+ +

InspectAddr

+
func InspectAddr(ctx context.Context, ip net.IP, domain string, perCall time.Duration) (*Details, error)
+

Dials ip:443 directly (bypassing the system resolver) while +presenting domain as SNI, capturing the TLS identity served at +one resolution-path endpoint. The CLI fetches one Details per +path (system-resolved IP vs public-resolved IP) so +ComparePaths can diff them. Same 3-attempt retry posture as +InspectDomain.

+ +
+ +

ComparePaths

+
func ComparePaths(sys, pub *Details) (match, complete bool, detail string)
+

Pure comparison of the TLS identities served at the system-resolved and +public-resolved endpoints: issuer organization, validation class, SAN +set (order/case-insensitive), and leaf SHA-256 fingerprint. Reports +whether both endpoints were interrogated (complete), whether +the identities agree (match), and a human-readable mismatch +description. Missing certificates fail open — incomplete, never a +mismatch. Safe to unit test.

+

FromLeaf

diff --git a/docs/dns.html b/docs/dns.html index 4da8db7..9af99c9 100644 --- a/docs/dns.html +++ b/docs/dns.html @@ -6,8 +6,11 @@

Package dns

Domain normalization, syntax validation, DNS pre-flight resolution -checks, and multi-resolver consistency cross-checks. Sources: -internal/dns/dns.go, internal/dns/resolvers.go.

+checks, multi-resolver consistency cross-checks, static hosts-file +override detection, origin-AS attribution, and SPF/DMARC sender-authority +probes. Sources: internal/dns/dns.go, +internal/dns/resolvers.go, internal/dns/hosts.go, +internal/dns/asn.go, internal/dns/spf.go.


@@ -32,13 +35,19 @@ dns.ValidSyntax("no-dot") // => false

DomainCheck

type DomainCheck struct {
-    SystemLive bool
-    PublicLive bool
+    SystemLive  bool
+    PublicLive  bool
+    SystemAddrs []net.IP
+    PublicAddrs []net.IP
+    HostsHit    bool
+    HostsIP     string
     Consistency Consistency
 }

One domain's full probe: system liveness for pre-flight gating, public -liveness for suppression attribution, and the aggregate consistency -verdict. The pre-flight gate aborts on system failure and reuses these +liveness for suppression attribution, the aggregate consistency +verdict, the first live A record per side (retained for per-path +interrogation — never scored by raw address), and the hosts-file override +state. The pre-flight gate aborts on system failure and reuses these outcomes downstream, so each resolver is queried exactly once per domain.

@@ -91,6 +100,78 @@ votes is never tampering. Never fails closed.

domainAlready-normalized domain to cross-check. timeoutPer-resolver deadline (the CLI --timeout value). + +
+ +

CheckHostsOverride

+
func CheckHostsOverride(domain string) (ip string, ok bool)
+

Reports a hardcoded static mapping for domain in the OS hosts +database (/etc/hosts on unix, +C:\Windows\System32\drivers\etc\hosts on Windows — see +HostsPath). Such a mapping short-circuits every resolver, so no +cross-check can see it; it is probed once in pre-flight alongside the +resolver queries. Fail-open: unreadable or missing files simply report no +hit, never an error.

+ +
+ +

ParseHostsFile

+
func ParseHostsFile(content, domain string) (string, bool)
+

Pure scanner behind CheckHostsOverride: ignores comment lines +and inline comments, matches hostnames case-insensitively (trailing-dot +tolerant), first mapping wins (OS resolver precedence). Safe to unit +test.

+ +
+ +

LookupASN

+
func LookupASN(ctx context.Context, ip net.IP, timeout time.Duration) (ASNInfo, bool)
+

Attributes ip to its origin autonomous system via Team +Cymru's DNS service (<reversed-octets>.origin.asn.cymru.com +TXT), queried through the pinned 1.1.1.1 resolver — never the system +resolver, so a poisoned local resolver cannot attest to its own +redirection. IPv4 only (IPv6 reports not-ok). Fail-open: unanswered or +unparsable queries yield not-ok. Result type:

+
type ASNInfo struct {
+    ASN    string // origin AS number, e.g. "15169"
+    Prefix string // announced prefix, e.g. "8.8.8.0/24"
+    CC     string // registrant country code, e.g. "US"
+}
+ +
+ +

ParseCymruTXT

+
func ParseCymruTXT(txt string) (ASNInfo, bool)
+

Pure parser for one Cymru origin answer +("15169 | 8.8.8.0/24 | US | arin | 1992-12-01"). Companion +helpers: ASNQueryName builds the query name (false for IPv6), +ASNMatch reports a shared origin AS (unknown sides never +match).

+ +
+ +

CheckSPFAuthority

+
func CheckSPFAuthority(ctx context.Context, target string, suspectIP net.IP, timeout time.Duration) (SPFResult, bool)
+

Evaluates the target's v=spf1 record for the suspect's IP (RFC +7208 mechanism subset: ip4/ip6 CIDRs, a, +mx, recursive include:, redirect=; only a ++-qualifier match authorizes). Recursive includes share the RFC +10-lookup budget; macro mechanisms, multiple v=spf1 records, and +lookup errors all fail open (not-ok). Result type:

+
type SPFResult struct {
+    Authorized bool   // the IP is covered by a pass-qualifier mechanism
+    Cover      string // the covering mechanism, e.g. "include:_spf.google.com"
+}
+ +
+ +

CheckDMARC

+
func CheckDMARC(ctx context.Context, target string, timeout time.Duration) (string, bool)
+

Reads the target's DMARC enforcement policy (none, +quarantine, reject) from +_dmarc.<target> TXT through the pinned resolver. +Display-only context (a p=reject target tolerates no +unauthorized senders); fail-open on any problem.


Licensed under the GNU General Public License v3 - Mark Robillard Jr (MARKMENTAL) diff --git a/docs/nav.html b/docs/nav.html index aab189d..39cf2fc 100644 --- a/docs/nav.html +++ b/docs/nav.html @@ -26,12 +26,20 @@
  • SplitKind
  • Consistency
  • CheckConsistency
  • +
  • CheckHostsOverride
  • +
  • ParseHostsFile
  • +
  • LookupASN
  • +
  • ParseCymruTXT
  • +
  • CheckSPFAuthority
  • +
  • CheckDMARC
  • cert

    diff --git a/docs/overview.html b/docs/overview.html index 585fe7f..041dd72 100644 --- a/docs/overview.html +++ b/docs/overview.html @@ -94,6 +94,7 @@ where possible, otherwise use -k/--insecure as a last resort.

    Rogue ASN: per-path AS split (suspect)+30 Hosts-file override (suspect)+50 Rogue signals target-side/inconclusive (info)+0 +Chronology inversion: suspect far older than young target (info)+0
    @@ -120,8 +121,8 @@ states.

    Packages

    func Human(d Data) string

    Triage report with per-domain blocks (creation date, registrar, issuer, -validation, SANs), the scored risk analysis, and the verdict. Empty +validation, SANs), the pair relationship line, the scored risk analysis, +and the verdict. Empty findings render a clean bill of health.


    @@ -50,7 +52,10 @@ findings render a clean bill of health.

    func JSON(d Data) string

    Machine-readable summary with tool, version, target, suspect (including suspect age_days), -risk_score, findings, and verdict. Ends with a +risk_score, findings, verdict, +relationship, suspect_spf (checked, +authorized, cover), target_dmarc, and per-path +target_paths / suspect_paths objects. Ends with a newline for pipeline use.


    @@ -58,7 +63,9 @@ newline for pipeline use.

    Verbose

    func Verbose(d Data) string

    Raw policy OIDs with classes, per-domain TLS details (issuer, subject, -validity, SANs), and raw RDAP JSON for both domains. Missing data renders +validity, SANs), raw RDAP JSON for both domains, DNS consistency verdicts, +and the Resolution paths section (per-path endpoints, per-path TLS +agreement, origin-AS status, hosts-override state). Missing data renders an explicit unavailable notice per section.


    Licensed under the GNU General Public License v3 - Mark Robillard Jr (MARKMENTAL) diff --git a/docs/score.html b/docs/score.html index f05547a..cb9b101 100644 --- a/docs/score.html +++ b/docs/score.html @@ -5,11 +5,13 @@

    Package score

    -

    Deterministic risk scoring: domain-age check (contextual tiers with -stacking and shared-cert discount), certificate -downgrade check (+40), and registrar mismatch (+30), mapped onto a -four-tier verdict. Sources: internal/score/score.go, -internal/score/date.go.

    +

    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.


    @@ -23,18 +25,44 @@ four-tier verdict. Sources: internal/score/score.go, 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.

    +

    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
    +    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.


    @@ -61,23 +89,41 @@ The CLI aborts instead of printing a verdict built on missing data.

    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; -shared certificate infrastructure (either domain in the other's SANs, -exactly or via parent wildcard) halves whatever remains. Target EV/OV versus +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. Suspect-side DNS suppression and NS +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). Identical domains force score zero and LEGITIMATE -while still attaching DNS NOTEs.

    +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.AgeFlag
    +// 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+ -unrelated/misconfigured, otherwise legitimate.

    +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.


    diff --git a/internal/report/report.go b/internal/report/report.go index b4585cb..25725ba 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -60,6 +60,8 @@ Signals: | Rogue ASN: per-path AS split (suspect) | +30 | | Hosts-file override (suspect) | +50 | | Rogue signals target-side/inconclusive (info) | +0 | + | Chronology inversion: suspect far older than | +0 | + | young target (info; likely swapped arguments) | | +-----------------------------------------------+--------+ Verdicts (* = unrelated pair: no shared SANs, SPF coverage, or label diff --git a/internal/score/chronology_test.go b/internal/score/chronology_test.go new file mode 100644 index 0000000..5d3a385 --- /dev/null +++ b/internal/score/chronology_test.go @@ -0,0 +1,65 @@ +// 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 + +import "testing" + +// invertedPair mirrors the flipped-arguments case: a days-old "target" +// against a decades-old suspect. The CHRONOLOGY finding must appear +// with score and verdict untouched. +func TestChronologyInversionFires(t *testing.T) { + target := DomainData{Domain: "jdsoftcareers.com", Date: "2026-09-02", Registrar: "NameSilo, LLC", Issuer: "Let's Encrypt", Class: "DV", AgeDays: 7, AgeKnown: true} + suspect := DomainData{Domain: "jdsoft.com", Date: "2002-11-06", Registrar: "Amazon Registrar, Inc.", Issuer: "GoDaddy.com, Inc.", Class: "DV", AgeDays: 24 * 365, AgeKnown: true} + r := Score(target, suspect) + if r.Score != 30 { + t.Errorf("Score = %d, want 30 (registrar mismatch only; inversion is display-only)", r.Score) + } + if r.Verdict != VerdictMaliciousOrNegligent { + t.Errorf("Verdict = %q, want %q (inversion never reshapes the verdict)", r.Verdict, VerdictMaliciousOrNegligent) + } + want := "CHRONOLOGY: Suspect jdsoft.com (24 years old) is far older than target jdsoftcareers.com (7 days old)" + if !findingsContain(r, want) { + t.Errorf("expected CHRONOLOGY finding, got %v", r.Findings) + } +} + +func TestChronologyInversionQuiet(t *testing.T) { + old := func(domain string) DomainData { + return DomainData{Domain: domain, Date: "2000-01-01", Registrar: "Same Inc.", Issuer: "CA", Class: "DV", AgeDays: 9000, AgeKnown: true} + } + cases := map[string]struct { + target, suspect DomainData + }{ + "both established": {old("a.com"), old("b.com")}, + "correct direction": {old("brand.com"), DomainData{Domain: "brand-new.com", Date: "2026-09-01", Registrar: "Same Inc.", Issuer: "CA", Class: "DV", AgeDays: 8, AgeKnown: true}}, + "gap below threshold": {DomainData{Domain: "t.com", Date: "2026-07-11", Registrar: "Same Inc.", Issuer: "CA", Class: "DV", AgeDays: 60, AgeKnown: true}, DomainData{Domain: "s.com", Date: "2026-02-21", Registrar: "Same Inc.", Issuer: "CA", Class: "DV", AgeDays: 200, AgeKnown: true}}, + "unknown target age": {DomainData{Domain: "t.com"}, old("s.com")}, + "identical domains": {old("same.com"), old("same.com")}, + "young pair both fresh": {DomainData{Domain: "t.com", Date: "2026-09-01", Registrar: "Same Inc.", Issuer: "CA", Class: "DV", AgeDays: 8, AgeKnown: true}, DomainData{Domain: "s.com", Date: "2026-08-25", Registrar: "Same Inc.", Issuer: "CA", Class: "DV", AgeDays: 15, AgeKnown: true}}, + } + for name, c := range cases { + r := Score(c.target, c.suspect) + if findingsContain(r, "CHRONOLOGY:") { + t.Errorf("%s: unexpected CHRONOLOGY finding: %v", name, r.Findings) + } + } +} + +func TestAgeSpan(t *testing.T) { + cases := map[int]string{ + 0: "0 days", + 7: "7 days", + 364: "364 days", + 365: "1 years", + 730: "2 years", + 8760: "24 years", + } + for days, want := range cases { + if got := ageSpan(days); got != want { + t.Errorf("ageSpan(%d) = %q, want %q", days, got, want) + } + } +} diff --git a/internal/score/score.go b/internal/score/score.go index a45beef..95118d2 100644 --- a/internal/score/score.go +++ b/internal/score/score.go @@ -144,6 +144,14 @@ func Score(target, suspect DomainData) Result { } } + // Chronology inversion: a suspect far older than a very young target + // hints the pair was fed backwards — a days-old "brand" has nothing + // to protect. Display-only: an old suspect is exonerating, never + // scored. + if chronologyInversion(target, suspect) { + r.Findings = append(r.Findings, fmt.Sprintf("CHRONOLOGY: Suspect %s (%s old) is far older than target %s (%s old) — chronological inversion: the 'target' is likely the imposter; re-run with the established brand as the target.", suspect.Domain, ageSpan(suspect.AgeDays), target.Domain, ageSpan(target.AgeDays))) + } + 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)) @@ -353,6 +361,24 @@ func levenshtein(a, b string) int { return prev[len(br)] } +// chronologyInversion flags a suspect far older than a very young +// target: the pair was likely fed backwards, since a days-old "brand" +// has nothing to protect. Thresholds reuse the WARNING tier boundary +// (<90d is not an established brand) and a one-year floor to keep +// ordinary age gaps quiet. Pure function. +func chronologyInversion(target, suspect DomainData) bool { + return target.AgeKnown && suspect.AgeKnown && + target.AgeDays < 90 && suspect.AgeDays >= 365 +} + +// ageSpan renders an age in days as "N days" or, past a year, "N years". +func ageSpan(days int) string { + if days >= 365 { + return fmt.Sprintf("%d years", days/365) + } + return fmt.Sprintf("%d days", days) +} + // registrarMismatch reports a case-insensitive registrar difference, // skipped when either side is unknown. func registrarMismatch(target, suspect DomainData) bool {