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:
parent
1e3e9c0a24
commit
26de54e680
10 changed files with 304 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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") // => 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><reversed-octets>.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.<target></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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (< 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
65
internal/score/chronology_test.go
Normal file
65
internal/score/chronology_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue