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

This commit is contained in:
markmental 2026-09-09 15:13:20 -04:00
commit 26de54e680
10 changed files with 304 additions and 37 deletions

View file

@ -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

View file

@ -15,15 +15,16 @@ classification from policy OIDs. Source: <tt>internal/cert/cert.go</tt>.</p>
<a name="Details"></a>
<font color="#859900"><h2>Details</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>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
}</pre></td></tr></table>
<p>Extracted leaf-certificate fields. <tt>Issuer</tt> prefers the
<tt>Organization</tt> 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.</p>
<tr><td><tt>perCall</tt></td><td>Per-attempt deadline (5 seconds in the CLI).</td></tr>
</table>
<hr>
<a name="InspectAddr"></a>
<font color="#859900"><h2>InspectAddr</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func InspectAddr(ctx context.Context, ip net.IP, domain string, perCall time.Duration) (*Details, error)</pre></td></tr></table>
<p>Dials <tt>ip:443</tt> directly (bypassing the system resolver) while
presenting <tt>domain</tt> as SNI, capturing the TLS identity served at
one resolution-path endpoint. The CLI fetches one <tt>Details</tt> per
path (system-resolved IP vs public-resolved IP) so
<tt>ComparePaths</tt> can diff them. Same 3-attempt retry posture as
<tt>InspectDomain</tt>.</p>
<hr>
<a name="ComparePaths"></a>
<font color="#859900"><h2>ComparePaths</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func ComparePaths(sys, pub *Details) (match, complete bool, detail string)</pre></td></tr></table>
<p>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 (<tt>complete</tt>), whether
the identities agree (<tt>match</tt>), and a human-readable mismatch
description. Missing certificates fail open — incomplete, never a
mismatch. Safe to unit test.</p>
<hr>
<a name="FromLeaf"></a>
<font color="#859900"><h2>FromLeaf</h2></font>

View file

@ -6,8 +6,11 @@
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>Package dns</h1></font>
<p>Domain normalization, syntax validation, DNS pre-flight resolution
checks, and multi-resolver consistency cross-checks. Sources:
<tt>internal/dns/dns.go</tt>, <tt>internal/dns/resolvers.go</tt>.</p>
checks, multi-resolver consistency cross-checks, static hosts-file
override detection, origin-AS attribution, and SPF/DMARC sender-authority
probes. Sources: <tt>internal/dns/dns.go</tt>,
<tt>internal/dns/resolvers.go</tt>, <tt>internal/dns/hosts.go</tt>,
<tt>internal/dns/asn.go</tt>, <tt>internal/dns/spf.go</tt>.</p>
<hr>
<a name="NormalizeDomain"></a>
@ -32,13 +35,19 @@ dns.ValidSyntax("no-dot") // =&gt; false</pre></td></tr></table>
<a name="DomainCheck"></a>
<font color="#859900"><h2>DomainCheck</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>type DomainCheck struct {
SystemLive bool
PublicLive bool
SystemLive bool
PublicLive bool
SystemAddrs []net.IP
PublicAddrs []net.IP
HostsHit bool
HostsIP string
Consistency Consistency
}</pre></td></tr></table>
<p>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.</p>
@ -91,6 +100,78 @@ votes is never tampering. Never fails closed.</p>
<tr><td><tt>domain</tt></td><td>Already-normalized domain to cross-check.</td></tr>
<tr><td><tt>timeout</tt></td><td>Per-resolver deadline (the CLI --timeout value).</td></tr>
</table>
<hr>
<a name="CheckHostsOverride"></a>
<font color="#859900"><h2>CheckHostsOverride</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func CheckHostsOverride(domain string) (ip string, ok bool)</pre></td></tr></table>
<p>Reports a hardcoded static mapping for <tt>domain</tt> in the OS hosts
database (<tt>/etc/hosts</tt> on unix,
<tt>C:\Windows\System32\drivers\etc\hosts</tt> on Windows — see
<tt>HostsPath</tt>). 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.</p>
<hr>
<a name="ParseHostsFile"></a>
<font color="#859900"><h2>ParseHostsFile</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func ParseHostsFile(content, domain string) (string, bool)</pre></td></tr></table>
<p>Pure scanner behind <tt>CheckHostsOverride</tt>: ignores comment lines
and inline comments, matches hostnames case-insensitively (trailing-dot
tolerant), first mapping wins (OS resolver precedence). Safe to unit
test.</p>
<hr>
<a name="LookupASN"></a>
<font color="#859900"><h2>LookupASN</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func LookupASN(ctx context.Context, ip net.IP, timeout time.Duration) (ASNInfo, bool)</pre></td></tr></table>
<p>Attributes <tt>ip</tt> to its origin autonomous system via Team
Cymru's DNS service (<tt>&lt;reversed-octets&gt;.origin.asn.cymru.com</tt>
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:</p>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>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"
}</pre></td></tr></table>
<hr>
<a name="ParseCymruTXT"></a>
<font color="#859900"><h2>ParseCymruTXT</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func ParseCymruTXT(txt string) (ASNInfo, bool)</pre></td></tr></table>
<p>Pure parser for one Cymru origin answer
(<tt>"15169 | 8.8.8.0/24 | US | arin | 1992-12-01"</tt>). Companion
helpers: <tt>ASNQueryName</tt> builds the query name (false for IPv6),
<tt>ASNMatch</tt> reports a shared origin AS (unknown sides never
match).</p>
<hr>
<a name="CheckSPFAuthority"></a>
<font color="#859900"><h2>CheckSPFAuthority</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func CheckSPFAuthority(ctx context.Context, target string, suspectIP net.IP, timeout time.Duration) (SPFResult, bool)</pre></td></tr></table>
<p>Evaluates the target's <tt>v=spf1</tt> record for the suspect's IP (RFC
7208 mechanism subset: <tt>ip4</tt>/<tt>ip6</tt> CIDRs, <tt>a</tt>,
<tt>mx</tt>, recursive <tt>include:</tt>, <tt>redirect=</tt>; only a
<tt>+</tt>-qualifier match authorizes). Recursive includes share the RFC
10-lookup budget; macro mechanisms, multiple <tt>v=spf1</tt> records, and
lookup errors all fail open (not-ok). Result type:</p>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>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"
}</pre></td></tr></table>
<hr>
<a name="CheckDMARC"></a>
<font color="#859900"><h2>CheckDMARC</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func CheckDMARC(ctx context.Context, target string, timeout time.Duration) (string, bool)</pre></td></tr></table>
<p>Reads the target's DMARC enforcement policy (<tt>none</tt>,
<tt>quarantine</tt>, <tt>reject</tt>) from
<tt>_dmarc.&lt;target&gt;</tt> TXT through the pinned resolver.
Display-only context (a <tt>p=reject</tt> target tolerates no
unauthorized senders); fail-open on any problem.</p>
<hr>
<font color="#b58900">Licensed under the GNU General Public License v3 - Mark Robillard Jr (MARKMENTAL)</font>
</body>

View file

@ -26,12 +26,20 @@
<li><a href="dns.html#SplitKind" target="content">SplitKind</a></li>
<li><a href="dns.html#Consistency" target="content">Consistency</a></li>
<li><a href="dns.html#CheckConsistency" target="content">CheckConsistency</a></li>
<li><a href="dns.html#CheckHostsOverride" target="content">CheckHostsOverride</a></li>
<li><a href="dns.html#ParseHostsFile" target="content">ParseHostsFile</a></li>
<li><a href="dns.html#LookupASN" target="content">LookupASN</a></li>
<li><a href="dns.html#ParseCymruTXT" target="content">ParseCymruTXT</a></li>
<li><a href="dns.html#CheckSPFAuthority" target="content">CheckSPFAuthority</a></li>
<li><a href="dns.html#CheckDMARC" target="content">CheckDMARC</a></li>
</ul>
<p><b>cert</b></p>
<ul>
<li><a href="cert.html#Details" target="content">Details</a></li>
<li><a href="cert.html#Classify" target="content">Classify</a></li>
<li><a href="cert.html#InspectDomain" target="content">InspectDomain</a></li>
<li><a href="cert.html#InspectAddr" target="content">InspectAddr</a></li>
<li><a href="cert.html#ComparePaths" target="content">ComparePaths</a></li>
<li><a href="cert.html#FromLeaf" target="content">FromLeaf</a></li>
<li><a href="cert.html#SANsString" target="content">SANsString</a></li>
</ul>

View file

@ -94,6 +94,7 @@ where possible, otherwise use <tt>-k/--insecure</tt> as a last resort.</p>
<tr><td>Rogue ASN: per-path AS split (suspect)</td><td>+30</td></tr>
<tr><td>Hosts-file override (suspect)</td><td>+50</td></tr>
<tr><td>Rogue signals target-side/inconclusive (info)</td><td>+0</td></tr>
<tr><td>Chronology inversion: suspect far older than young target (info)</td><td>+0</td></tr>
</table>
<br>
<table border="1" cellpadding="4">
@ -120,8 +121,8 @@ states.</p>
<font color="#859900"><h2>Packages</h2></font>
<ul>
<li><a href="dns.html"><b>dns</b></a> - normalization, syntax checks, DNS pre-flight.</li>
<li><a href="cert.html"><b>cert</b></a> - TLS leaf fetch and validation-level classification.</li>
<li><a href="dns.html"><b>dns</b></a> - normalization, syntax checks, DNS pre-flight, hosts overrides, origin-AS attribution, SPF/DMARC sender authority.</li>
<li><a href="cert.html"><b>cert</b></a> - TLS leaf fetch, validation-level classification, per-path identity comparison.</li>
<li><a href="rdap.html"><b>rdap</b></a> - RDAP client, creation date and registrar extraction.</li>
<li><a href="score.html"><b>score</b></a> - risk scoring and verdict mapping.</li>
<li><a href="report.html"><b>report</b></a> - human, JSON, scale, and verbose renderers.</li>

View file

@ -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.</p>
<font color="#859900"><h2>Human</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func Human(d Data) string</pre></td></tr></table>
<p>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.</p>
<hr>
@ -50,7 +52,10 @@ findings render a clean bill of health.</p>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func JSON(d Data) string</pre></td></tr></table>
<p>Machine-readable summary with <tt>tool</tt>, <tt>version</tt>,
<tt>target</tt>, <tt>suspect</tt> (including suspect <tt>age_days</tt>),
<tt>risk_score</tt>, <tt>findings</tt>, and <tt>verdict</tt>. Ends with a
<tt>risk_score</tt>, <tt>findings</tt>, <tt>verdict</tt>,
<tt>relationship</tt>, <tt>suspect_spf</tt> (<tt>checked</tt>,
<tt>authorized</tt>, <tt>cover</tt>), <tt>target_dmarc</tt>, and per-path
<tt>target_paths</tt> / <tt>suspect_paths</tt> objects. Ends with a
newline for pipeline use.</p>
<hr>
@ -58,7 +63,9 @@ newline for pipeline use.</p>
<font color="#859900"><h2>Verbose</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func Verbose(d Data) string</pre></td></tr></table>
<p>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.</p>
<hr>
<font color="#b58900">Licensed under the GNU General Public License v3 - Mark Robillard Jr (MARKMENTAL)</font>

View file

@ -5,11 +5,13 @@
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>Package score</h1></font>
<p>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: <tt>internal/score/score.go</tt>,
<tt>internal/score/date.go</tt>.</p>
<p>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:
<tt>internal/score/score.go</tt>, <tt>internal/score/date.go</tt>. Max
total is 330.</p>
<hr>
<a name="DomainData"></a>
@ -23,18 +25,44 @@ four-tier verdict. Sources: <tt>internal/score/score.go</tt>,
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
}</pre></td></tr></table>
<p>Collected triage fields for one domain; the scoring input.</p>
<p>Collected triage fields for one domain; the scoring input. Raw
addresses are retained for per-path interrogation only and are never
compared for points.</p>
<hr>
<a name="Result"></a>
<font color="#859900"><h2>Result</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>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
}</pre></td></tr></table>
<p>Scoring outcome. <tt>Relationship</tt> names the target/suspect
relationship (see <tt>Score</tt>); it gates the LEGITIMATE verdict.</p>
<hr>
<a name="ClassLong"></a>
@ -61,23 +89,41 @@ The CLI aborts instead of printing a verdict built on missing data.</p>
while still attaching DNS NOTEs. Suspect age
is contextual: the full tier (&lt; 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.</p>
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).</p>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>r := score.Score(target, suspect)
// r.Score, r.Findings, r.Verdict, r.AgeFlag</pre></td></tr></table>
// r.Score, r.Findings, r.Verdict, r.Relationship, r.AgeFlag</pre></td></tr></table>
<hr>
<a name="Verdict"></a>
<font color="#859900"><h2>Verdict</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>func Verdict(score int) string</pre></td></tr></table>
<p>Maps the total via threshold table: 51+ phishing, 40+ suspicious, 15+
unrelated/misconfigured, otherwise legitimate.</p>
malicious-or-negligent (related pairs), otherwise legitimate (related
pairs). Unrelated pairs scoring 0-39 map to the UNRELATED verdict
instead — see <tt>Score</tt>. Verdict strings live in named constants
(<tt>VerdictPhishing</tt>, <tt>VerdictSuspicious</tt>,
<tt>VerdictMaliciousOrNegligent</tt>, <tt>VerdictLegitimate</tt>,
<tt>VerdictUnrelated</tt>); relationships in <tt>RelIdentical</tt>,
<tt>RelSANEndorsed</tt>, <tt>RelSPFEndorsed</tt>, <tt>RelLookalike</tt>,
<tt>RelUnrelated</tt>. Max total is 330.</p>
<hr>
<a name="ValidISODate"></a>

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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 {