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.
| perCall | Per-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.
| domain | Already-normalized domain to cross-check. |
| timeout | Per-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
-- dns - normalization, syntax checks, DNS pre-flight.
-- cert - TLS leaf fetch and validation-level classification.
+- dns - normalization, syntax checks, DNS pre-flight, hosts overrides, origin-AS attribution, SPF/DMARC sender authority.
+- cert - TLS leaf fetch, validation-level classification, per-path identity comparison.
- rdap - RDAP client, creation date and registrar extraction.
- score - risk scoring and verdict mapping.
- report - human, JSON, scale, and verbose renderers.
diff --git a/docs/report.html b/docs/report.html
index ececee8..413d41e 100644
--- a/docs/report.html
+++ b/docs/report.html
@@ -18,6 +18,7 @@
Score int
Findings []string
Verdict string
+ Relationship string
AgeFlag string
TargetCert *cert.Details
SuspectCert *cert.Details
@@ -41,7 +42,8 @@ RDAP results backing verbose output.
Human
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.
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 {