Rename to phisherslapper, better detect Rogue DNS Redirection

This commit is contained in:
markmental 2026-09-09 12:26:51 -04:00
commit 2bda98917a
25 changed files with 877 additions and 98 deletions

4
.gitignore vendored
View file

@ -1,5 +1,5 @@
# Agent spec isn't directly relevant to code
AGENTS.md
.clang-format
# Built Go binary (replaces bash script in place; bash kept as isthislegit.sh)
/isthislegit*
# Built Go binary (replaces bash script in place; bash kept as phisherslapper.sh)
/phisherslapper*

View file

@ -1,8 +1,8 @@
# isthislegit? [v1.01]
# phisherslapper [v1.01]
Domain Impersonation & OSINT Triage Tool.
`isthislegit` compares a known-legitimate domain (target) against a suspect
`phisherslapper` compares a known-legitimate domain (target) against a suspect
domain using DNS resolution, X.509 TLS certificates, and RDAP registration
data to produce a deterministic risk score and verdict. DNS answers are
cross-checked across the system resolver, 1.1.1.1, and 8.8.8.8 for
@ -19,6 +19,9 @@ required.
## Features
- **DNS pre-flight** — NXDOMAIN / unresolvable domains abort early.
- **Rogue-redirection probes** — per-path TLS identity comparison (local
vs public endpoint), origin-AS attribution via Team Cymru, and a
hosts-file override check.
- **X.509 classification** — leaf certificate issuer, SANs, and validation
level (EV / OV / IV / DV) from CA/Browser Forum policy OIDs.
- **RDAP triage** — creation date and registrar via `https://rdap.org/domain/`.
@ -29,15 +32,17 @@ required.
## Prerequisites
- Go 1.24 or newer.
- Network access to DNS, remote port 443, and `rdap.org`.
- Network access to DNS, remote port 443, and `rdap.org`. Origin-AS
attribution queries `origin.asn.cymru.com` (Team Cymru) through
1.1.1.1; if that zone is unreachable the ASN signal fails open.
## Clone, build, run
```bash
git clone https://github.com/MARKMENTAL/isthislegit.git
cd isthislegit
CGO_ENABLED=0 go build -ldflags="-s -w" -o isthislegit ./cmd/isthislegit
./isthislegit jdsoft.com jdsoftcareers.com
git clone https://github.com/MARKMENTAL/phisherslapper.git
cd phisherslapper
CGO_ENABLED=0 go build -ldflags="-s -w" -o phisherslapper ./cmd/phisherslapper
./phisherslapper jdsoft.com jdsoftcareers.com
```
Run the test suite with:
@ -49,7 +54,7 @@ go test ./...
### Cross-compiling with compile.go
`compile.go` (repo root) is the supported build driver. It cross-compiles
`./cmd/isthislegit` for a platform/arch matrix, always with
`./cmd/phisherslapper` for a platform/arch matrix, always with
`CGO_ENABLED=0`, so every target builds without a cross C toolchain:
```bash
@ -72,8 +77,8 @@ Arch vocabulary: raw `GOARCH` names plus aliases — `x64`/`x86-64` → `amd64`;
toolchain does not support (e.g. `darwin/386`) are refused with a clear
error.
Output binaries are named `isthislegit-<goos>-<goarch>` (with a `vGOARM`
suffix when applicable, e.g. `isthislegit-linux-armv7`, and `.exe` on
Output binaries are named `phisherslapper-<goos>-<goarch>` (with a `vGOARM`
suffix when applicable, e.g. `phisherslapper-linux-armv7`, and `.exe` on
Windows):
```bash
@ -97,15 +102,15 @@ go run ./compile.go --platform=linux --arch=armv7 # embedded ARM (Miyoo Mini P
Examples:
```bash
isthislegit jdsoft.com jdsoftcareers.com
isthislegit -j example.com suspect-example.com
isthislegit --timeout=10s example.com suspect-example.com
isthislegit --scale
phisherslapper jdsoft.com jdsoftcareers.com
phisherslapper -j example.com suspect-example.com
phisherslapper --timeout=10s example.com suspect-example.com
phisherslapper --scale
```
### Embedded systems (no CA certificates)
`isthislegit` runs on embedded ARM devices (verified on a Miyoo Mini Plus),
`phisherslapper` runs on embedded ARM devices (verified on a Miyoo Mini Plus),
but minimal firmware images often ship without a CA certificate store. In
that case DNS resolution and certificate inspection still work, while
verified HTTPS (RDAP) fails — the fail-closed error will say so directly
@ -114,7 +119,7 @@ verified HTTPS (RDAP) fails — the fail-closed error will say so directly
insecure mode:
```bash
isthislegit -k jdsoft.com jdsoftcareers.com
phisherslapper -k jdsoft.com jdsoftcareers.com
```
`--insecure` prints a warning and weakens results, so treat it as a last
@ -123,7 +128,7 @@ resort for devices without stored CA certificates.
## Example output
```text
isthislegit? [v1.01]
phisherslapper [v1.01]
Domain Impersonation & OSINT Triage Tool
[*] Comparing: example.com (Target) <---> example.org (Suspect)
@ -164,6 +169,10 @@ VERDICT: LIKELY UNRELATED / MISCONFIGURED
| Cert downgrade: target EV/OV vs suspect DV | +40 |
| Registrar mismatch | +30 |
| Same-class issuer difference (display only) | +0 |
| Rogue TLS: per-path cert divergence (suspect) | +50 |
| Rogue ASN: per-path AS split (suspect) | +30 |
| Hosts-file override (suspect) | +50 |
| Rogue signals target-side/inconclusive (info) | +0 |
| Score | Verdict |
|-------|----------------------------------------|
@ -184,7 +193,7 @@ resort, with a warning.
## Project layout
```text
cmd/isthislegit CLI parsing, fetch orchestration, output dispatch
cmd/phisherslapper CLI parsing, fetch orchestration, output dispatch
internal/dns Domain normalization, syntax checks, DNS pre-flight
internal/cert TLS leaf fetch and validation-level classification
internal/rdap RDAP client, creation date and registrar extraction
@ -194,6 +203,6 @@ internal/report Human, JSON, scale, and verbose renderers
## License
Copyright (C) 2026 Mark Robillard Jr (MARKMENTAL). `isthislegit` is free
Copyright (C) 2026 Mark Robillard Jr (MARKMENTAL). `phisherslapper` is free
software licensed under the GNU General Public License v3 — see
[LICENSE](LICENSE) for the full text.

View file

@ -3,7 +3,7 @@
// it under the terms of the GNU General Public License v3. See LICENSE.
// SPDX-License-Identifier: GPL-3.0-only
// Command isthislegit compares a known-legitimate domain against a suspect
// Command phisherslapper compares a known-legitimate domain against a suspect
// domain using X.509 TLS certificates, RDAP registration data, and DNS
// resolution to produce a risk score and verdict. Stdlib only, plus
// golang.org/x/sync/errgroup for the concurrent pipeline.
@ -13,6 +13,7 @@ import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"os"
"strings"
@ -20,11 +21,11 @@ import (
"golang.org/x/sync/errgroup"
"isthislegit/internal/cert"
"isthislegit/internal/dns"
"isthislegit/internal/rdap"
"isthislegit/internal/report"
"isthislegit/internal/score"
"phisherslapper/internal/cert"
"phisherslapper/internal/dns"
"phisherslapper/internal/rdap"
"phisherslapper/internal/report"
"phisherslapper/internal/score"
)
const version = "1.01"
@ -33,11 +34,11 @@ const defaultPerCall = 5 * time.Second
const globalTimeout = 15 * time.Second
func usage() string {
return fmt.Sprintf(`isthislegit? [v%s]
return fmt.Sprintf(`phisherslapper [v%s]
Domain Impersonation & OSINT Triage Tool
Usage:
isthislegit [OPTIONS] <legit_domain> <suspect_domain>
phisherslapper [OPTIONS] <legit_domain> <suspect_domain>
Options:
-v, --verbose Output raw JSON responses from RDAP and full TLS cert details.
@ -49,19 +50,25 @@ Options:
-h, --help Display this usage banner and exit.
Examples:
isthislegit jdsoft.com jdsoftcareers.com
isthislegit -j example.com suspect-example.com
isthislegit --timeout=10s example.com suspect-example.com
phisherslapper jdsoft.com jdsoftcareers.com
phisherslapper -j example.com suspect-example.com
phisherslapper --timeout=10s example.com suspect-example.com
`, version)
}
type domainResult struct {
info score.DomainData
cert *cert.Details
rdap *rdap.Result
cons dns.Consistency
rdapErr error
certErr error
info score.DomainData
cert *cert.Details
rdap *rdap.Result
cons dns.Consistency
sysCert *cert.Details
pubCert *cert.Details
sysASN dns.ASNInfo
pubASN dns.ASNInfo
sysASNOK bool
pubASNOK bool
rdapErr error
certErr error
}
func fetchDomain(ctx context.Context, client *http.Client, domain string, now time.Time, perCall time.Duration, dc dns.DomainCheck) domainResult {
@ -81,6 +88,48 @@ func fetchDomain(ctx context.Context, client *http.Client, domain string, now ti
cd, res.certErr = c, err
return err
})
// Rogue-redirection probes: interrogate what each resolution path
// serves. Per-path TLS dials the concrete endpoint IP directly (SNI
// preserved); ASN attribution goes through the pinned resolver so a
// poisoned system resolver cannot attest to its own redirection.
var sysIP, pubIP net.IP
if len(dc.SystemAddrs) > 0 {
sysIP = dc.SystemAddrs[0]
}
if len(dc.PublicAddrs) > 0 {
pubIP = dc.PublicAddrs[0]
}
if sysIP != nil && pubIP != nil {
if sysIP.Equal(pubIP) {
// Same endpoint both paths: one dial attests for both.
g.Go(func() error {
c, _ := cert.InspectAddr(ctx, sysIP, domain, perCall)
res.sysCert, res.pubCert = c, c
return nil
})
} else {
g.Go(func() error {
c, _ := cert.InspectAddr(ctx, sysIP, domain, perCall)
res.sysCert = c
return nil
})
g.Go(func() error {
c, _ := cert.InspectAddr(ctx, pubIP, domain, perCall)
res.pubCert = c
return nil
})
}
g.Go(func() error {
a, ok := dns.LookupASN(ctx, sysIP, perCall)
res.sysASN, res.sysASNOK = a, ok
return nil
})
g.Go(func() error {
a, ok := dns.LookupASN(ctx, pubIP, perCall)
res.pubASN, res.pubASNOK = a, ok
return nil
})
}
// DNS was probed once in pre-flight; reuse those outcomes here so no
// resolver is queried twice. Fail-open per field; the root causes are
// kept for the fail-closed verdict gate below.
@ -92,6 +141,31 @@ func fetchDomain(ctx context.Context, client *http.Client, domain string, now ti
res.info.DNSTampered = res.cons.Tampered
res.info.DNSSplit = res.cons.Kind
res.info.DNSDetail = res.cons.Detail
res.info.HostsHit = dc.HostsHit
res.info.HostsIP = dc.HostsIP
if sysIP != nil {
res.info.SysIP = sysIP.String()
}
if pubIP != nil {
res.info.PubIP = pubIP.String()
}
if sysIP != nil && pubIP != nil {
res.info.PathChecked = true
match, complete, detail := cert.ComparePaths(res.sysCert, res.pubCert)
res.info.PathComplete = complete
res.info.PathMatch = match
res.info.PathDetail = detail
if res.sysASNOK {
res.info.SysASN = "AS" + res.sysASN.ASN
res.info.SysASNNet = asnNet(res.sysASN)
}
if res.pubASNOK {
res.info.PubASN = "AS" + res.pubASN.ASN
res.info.PubASNNet = asnNet(res.pubASN)
}
res.info.ASNChecked = res.sysASNOK && res.pubASNOK
res.info.ASNMatch = dns.ASNMatch(res.sysASN, res.pubASN, res.sysASNOK, res.pubASNOK)
}
res.info.Date = rdap.RegistrationDate(rd)
res.info.Registrar = rdap.Registrar(rd)
@ -123,6 +197,14 @@ func fetchDomain(ctx context.Context, client *http.Client, domain string, now ti
return res
}
// asnNet formats ASN attribution detail for display.
func asnNet(a dns.ASNInfo) string {
if a.Prefix != "" && a.CC != "" {
return a.Prefix + ", " + a.CC
}
return a.Prefix + a.CC
}
func run() int {
var verbose, jsonOut, insecure bool
var perCall = defaultPerCall

View file

@ -3,8 +3,8 @@
// it under the terms of the GNU General Public License v3. See LICENSE.
// SPDX-License-Identifier: GPL-3.0-only
// Command compile is the build driver for isthislegit. It cross-compiles
// ./cmd/isthislegit for the requested platform/arch matrix:
// Command compile is the build driver for phisherslapper. It cross-compiles
// ./cmd/phisherslapper for the requested platform/arch matrix:
//
// go run ./compile.go --platform=linux --arch=amd64 --static=true
//
@ -104,7 +104,7 @@ func supportedPairs() (map[string]bool, error) {
}
func outputName(t target) string {
name := fmt.Sprintf("isthislegit-%s-%s", t.goos, t.goarch)
name := fmt.Sprintf("phisherslapper-%s-%s", t.goos, t.goarch)
if t.goarm != "" {
name += "v" + t.goarm
}
@ -141,7 +141,7 @@ func build(t target, cgo bool) error {
if cgo {
cgoEnabled = "1"
}
args = append(args, "-o", outputName(t), "./cmd/isthislegit")
args = append(args, "-o", outputName(t), "./cmd/phisherslapper")
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(),

View file

@ -1,7 +1,7 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Developer Docs - Package cert</title>
<title>phisherslapper Developer Docs - Package cert</title>
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>Package cert</h1></font>

View file

@ -1,7 +1,7 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Developer Docs - Package dns</title>
<title>phisherslapper Developer Docs - Package dns</title>
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>Package dns</h1></font>

View file

@ -1,7 +1,7 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Developer Docs</title>
<title>phisherslapper Developer Docs</title>
</head>
<frameset cols="220,*">
<frame src="nav.html" name="nav">

View file

@ -1,16 +1,16 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Developer Docs - Command main</title>
<title>phisherslapper Developer Docs - Command main</title>
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>Command main</h1></font>
<p>CLI entry point and fetch orchestration. Source:
<tt>cmd/isthislegit/main.go</tt>. Package <tt>main</tt> exposes no exported
<tt>cmd/phisherslapper/main.go</tt>. Package <tt>main</tt> exposes no exported
symbols; this page documents behavior.</p>
<font color="#859900"><h2>Arguments</h2></font>
<p>Syntax: <tt>isthislegit [FLAGS] &lt;legit_domain&gt;
<p>Syntax: <tt>phisherslapper [FLAGS] &lt;legit_domain&gt;
&lt;suspect_domain&gt;</tt>. Flags <tt>-v/--verbose</tt>,
<tt>-j/--json</tt>, <tt>-k/--insecure</tt> (skips RDAP TLS verification with
a warning), <tt>--timeout=DUR</tt> (Go duration, default 5s),
@ -35,11 +35,14 @@ include the underlying fetch cause(s).</p>
pinned resolvers; system failure aborts (with hijack-aware messaging when
public resolvers resolve live). Outcomes are reused downstream, so no
resolver is queried twice - identical domains are fetched once and the
result shared.</li>
result shared. The first live A record per side is retained for per-path
probes, and the OS hosts file is checked for static overrides.</li>
<li>Per-domain fetch in parallel: RDAP
(<a href="rdap.html#Query">rdap.Query</a>) plus TLS
(<a href="cert.html#InspectDomain">cert.InspectDomain</a>) concurrently.
Field-level failures stay open (Unknown) here.</li>
(<a href="cert.html#InspectDomain">cert.InspectDomain</a>) concurrently,
plus rogue-redirection probes: per-path TLS at the system-resolved and
public-resolved endpoint IPs (SNI preserved) and origin-AS attribution for
both IPs via Team Cymru. Field-level failures stay open (Unknown) here.</li>
<li>Fail-closed gate:
<a href="score.html#VerifyDataQuality">score.VerifyDataQuality</a>; aborts
on missing dates or TLS data for either side.</li>
@ -53,8 +56,8 @@ plus <a href="report.html#Verbose">report.Verbose</a> when verbose.</li>
<font color="#859900"><h2>Version</h2></font>
<p>Current version string is <tt>1.01</tt>, printed in the banner, JSON
output, and <tt>--scale</tt> header. Build target:
<tt>CGO_ENABLED=0 go build -ldflags="-s -w" -o isthislegit
./cmd/isthislegit</tt>.</p>
<tt>CGO_ENABLED=0 go build -ldflags="-s -w" -o phisherslapper
./cmd/phisherslapper</tt>.</p>
<hr>
<font color="#b58900">Licensed under the GNU General Public License v3 - Mark Robillard Jr (MARKMENTAL)</font>
</body>

View file

@ -1,10 +1,10 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Docs - Navigation</title>
<title>phisherslapper Docs - Navigation</title>
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h3>isthislegit?</h3></font>
<font color="#b58900"><h3>phisherslapper</h3></font>
<p><a href="overview.html" target="content"><b>Overview</b></a></p>
<hr>
<p><b>Packages</b></p>

View file

@ -1,10 +1,10 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Developer Docs - Overview</title>
<title>phisherslapper Developer Docs - Overview</title>
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>isthislegit? [v1.01]</h1></font>
<font color="#b58900"><h1>phisherslapper [v1.01]</h1></font>
<p>Domain Impersonation and OSINT Triage Tool. Compares a known-legitimate
domain (target) against a suspect domain using DNS resolution, X.509 TLS
certificates, and RDAP registration data, then produces a deterministic risk
@ -12,10 +12,10 @@ score and verdict. Single static binary; standard library plus
<tt>golang.org/x/sync</tt> only.</p>
<font color="#859900"><h2>Build and Run</h2></font>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>git clone https://github.com/MARKMENTAL/isthislegit.git
cd isthislegit
CGO_ENABLED=0 go build -ldflags="-s -w" -o isthislegit ./cmd/isthislegit
./isthislegit jdsoft.com jdsoftcareers.com
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>git clone https://github.com/MARKMENTAL/phisherslapper.git
cd phisherslapper
CGO_ENABLED=0 go build -ldflags="-s -w" -o phisherslapper ./cmd/phisherslapper
./phisherslapper jdsoft.com jdsoftcareers.com
go test ./...</pre></td></tr></table>
<font color="#859900"><h2>Flags</h2></font>
@ -36,10 +36,10 @@ DNS, TLS, and RDAP call carries a 5 second deadline (adjustable with
<tt>--timeout</tt>) and is retried up to 3 times. Total run time equals the
slower of the two domains. Fail-closed errors name the underlying
cause(s).</p>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>parse args --&gt; DNS pre-flight (target + suspect, parallel)
--&gt; fetch target = RDAP + TLS in parallel
--&gt; fetch suspect = RDAP + TLS in parallel
--&gt; fail-closed verify --&gt; score --&gt; render</pre></td></tr></table>
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>parse args --&gt; DNS pre-flight (target + suspect, parallel; keeps per-path IPs, checks hosts file)
--&gt; fetch target = RDAP + TLS + per-path TLS + ASN in parallel
--&gt; fetch suspect = RDAP + TLS + per-path TLS + ASN in parallel
--&gt; fail-closed verify --&gt; score --&gt; render</pre></td></tr></table>
<p>Exit codes: <tt>0</tt> on success (including <tt>--scale</tt> and
<tt>--help</tt>), <tt>1</tt> on bad flags, invalid domains, DNS failure, or
failed data-quality verification.</p>
@ -54,6 +54,18 @@ display-only NOTEs. CDN/GeoDNS address churn stays quiet. Inconclusive
resolvers abstain; filtered resolvers are excluded by design. The
pre-flight gate aborts on system failure with hijack-aware messaging.</p>
<font color="#859900"><h2>Rogue Redirection</h2></font>
<p>Beyond state comparison, the first live A record per side is retained and
each endpoint is interrogated: the TLS identity served at the
system-resolved IP is compared against the public-resolved IP (issuer,
validation class, SANs, leaf fingerprint; SNI preserved). Origin
autonomous systems for both IPs are attributed via Team Cymru
(<tt>origin.asn.cymru.com</tt> TXT through the pinned resolver), and the OS
hosts file is checked for static overrides. Divergent per-path TLS (+50),
split origin AS (+30), and hosts-file overrides (+50) score on the suspect;
target-side and inconclusive probes stay display-only. Raw addresses are
never compared, so CDN/GeoDNS fronting stays quiet. Max total is 330.</p>
<font color="#859900"><h2>Embedded Systems</h2></font>
<p>The tool runs on embedded ARM devices (verified on a Miyoo Mini Plus),
but minimal firmware often ships without a CA certificate store. DNS and
@ -76,6 +88,10 @@ where possible, otherwise use <tt>-k/--insecure</tt> as a last resort.</p>
<tr><td>Cert downgrade: target EV/OV vs suspect DV</td><td>+40</td></tr>
<tr><td>Registrar mismatch</td><td>+30</td></tr>
<tr><td>Same-class issuer difference (display only)</td><td>+0</td></tr>
<tr><td>Rogue TLS: per-path cert divergence (suspect)</td><td>+50</td></tr>
<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>
</table>
<br>
<table border="1" cellpadding="4">

View file

@ -1,7 +1,7 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Developer Docs - Package rdap</title>
<title>phisherslapper Developer Docs - Package rdap</title>
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>Package rdap</h1></font>

View file

@ -1,7 +1,7 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Developer Docs - Package report</title>
<title>phisherslapper Developer Docs - Package report</title>
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>Package report</h1></font>

View file

@ -1,7 +1,7 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>isthislegit Developer Docs - Package score</title>
<title>phisherslapper Developer Docs - Package score</title>
</head>
<body bgcolor="#002b36" text="#a3b8b9" link="#859900" vlink="#b58900">
<font color="#b58900"><h1>Package score</h1></font>

2
go.mod
View file

@ -1,4 +1,4 @@
module isthislegit
module phisherslapper
go 1.24

View file

@ -10,10 +10,12 @@ package cert
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"sort"
"strings"
"time"
)
@ -28,15 +30,16 @@ const (
// Details holds the extracted leaf certificate fields.
type Details struct {
Issuer string // Organization or fallback issuer string, "Unknown" on failure
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, "Unknown" on failure
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
}
// oidClassPriority maps policy OIDs to validation classes in priority
@ -90,6 +93,35 @@ func InspectDomain(ctx context.Context, domain string, perCall time.Duration) (*
}
func inspectOnce(ctx context.Context, domain string, perCall time.Duration) (*Details, error) {
return dialOnce(ctx, net.JoinHostPort(domain, "443"), domain, perCall)
}
// InspectAddr dials ip:443 directly (bypassing the system resolver) while
// still presenting domain as SNI, capturing the TLS identity served at
// that specific resolution-path endpoint. Used to compare what the local
// resolver's answer serves versus what the public resolvers' answer
// serves. Same retry posture as InspectDomain.
func InspectAddr(ctx context.Context, ip net.IP, domain string, perCall time.Duration) (*Details, error) {
const maxAttempts = 3
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
d, err := dialOnce(ctx, net.JoinHostPort(ip.String(), "443"), domain, perCall)
if err == nil {
return d, nil
}
lastErr = err
if attempt < maxAttempts {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Second):
}
}
}
return nil, lastErr
}
func dialOnce(ctx context.Context, addr, serverName string, perCall time.Duration) (*Details, error) {
callCtx, cancel := context.WithTimeout(ctx, perCall)
defer cancel()
@ -97,22 +129,22 @@ func inspectOnce(ctx context.Context, domain string, perCall time.Duration) (*De
NetDialer: &net.Dialer{Timeout: perCall},
Config: &tls.Config{
InsecureSkipVerify: true,
ServerName: domain,
ServerName: serverName,
},
}
conn, err := dialer.DialContext(callCtx, "tcp", net.JoinHostPort(domain, "443"))
conn, err := dialer.DialContext(callCtx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("TLS dial %s: %w", domain, err)
return nil, fmt.Errorf("TLS dial %s: %w", addr, err)
}
defer conn.Close()
cs, ok := conn.(*tls.Conn)
if !ok {
return nil, fmt.Errorf("TLS dial %s: unexpected conn type", domain)
return nil, fmt.Errorf("TLS dial %s: unexpected conn type", addr)
}
state := cs.ConnectionState()
if len(state.PeerCertificates) == 0 {
return nil, fmt.Errorf("TLS dial %s: no peer certificates", domain)
return nil, fmt.Errorf("TLS dial %s: no peer certificates", addr)
}
return FromLeaf(state.PeerCertificates[0]), nil
}
@ -122,6 +154,7 @@ func FromLeaf(leaf *x509.Certificate) *Details {
d := &Details{HasCert: true}
d.Subject = leaf.Subject.String()
d.IssuerStr = leaf.Issuer.String()
d.Fingerprint = fmt.Sprintf("%x", sha256.Sum256(leaf.Raw))
d.NotBefore = leaf.NotBefore
d.NotAfter = leaf.NotAfter
@ -164,3 +197,62 @@ func (d *Details) SANsString() string {
}
return strings.Join(d.SANs, ", ")
}
// ComparePaths compares the TLS identities served at the system-resolved
// endpoint versus the public-resolved endpoint for one domain. It reports
// whether both endpoints were interrogated (complete), whether the
// identities agree (match), and a human-readable mismatch description.
// Pure function; safe to unit test. Missing certs fail open: incomplete,
// never a mismatch.
func ComparePaths(sys, pub *Details) (match, complete bool, detail string) {
if sys == nil || pub == nil || !sys.HasCert || !pub.HasCert {
return false, false, ""
}
var diffs []string
if sys.Issuer != pub.Issuer {
diffs = append(diffs, fmt.Sprintf("issuer %q vs %q", sys.Issuer, pub.Issuer))
}
if sys.Class != pub.Class {
diffs = append(diffs, fmt.Sprintf("validation %s vs %s", sys.Class, pub.Class))
}
if !sansEqual(sys.SANs, pub.SANs) {
diffs = append(diffs, fmt.Sprintf("SANs {%s} vs {%s}", sys.SANsString(), pub.SANsString()))
}
if sys.Fingerprint != "" && pub.Fingerprint != "" && sys.Fingerprint != pub.Fingerprint {
diffs = append(diffs, fmt.Sprintf("leaf fingerprint %s vs %s", shortFP(sys.Fingerprint), shortFP(pub.Fingerprint)))
}
if len(diffs) == 0 {
return true, true, ""
}
return false, true, strings.Join(diffs, "; ")
}
// sansEqual compares SAN sets ignoring order and case.
func sansEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
norm := func(s []string) []string {
out := make([]string, len(s))
for i, v := range s {
out[i] = strings.ToLower(strings.TrimSpace(v))
}
sort.Strings(out)
return out
}
na, nb := norm(a), norm(b)
for i := range na {
if na[i] != nb[i] {
return false
}
}
return true
}
// shortFP truncates a fingerprint for display.
func shortFP(fp string) string {
if len(fp) > 16 {
return fp[:16] + "…"
}
return fp
}

View file

@ -0,0 +1,58 @@
// 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 cert
import (
"strings"
"testing"
)
func pathDetails() (*Details, *Details) {
a := &Details{HasCert: true, Issuer: "DigiCert Inc", Class: "EV",
SANs: []string{"example.com", "www.example.com"}, Fingerprint: strings.Repeat("a", 64)}
b := &Details{HasCert: true, Issuer: "DigiCert Inc", Class: "EV",
SANs: []string{"www.example.com", "EXAMPLE.COM"}, Fingerprint: strings.Repeat("a", 64)}
return a, b
}
func TestComparePathsMatch(t *testing.T) {
a, b := pathDetails()
match, complete, detail := ComparePaths(a, b)
if !match || !complete || detail != "" {
t.Errorf("got (%v, %v, %q), want (true, true, \"\")", match, complete, detail)
}
}
func TestComparePathsMismatch(t *testing.T) {
a, b := pathDetails()
b.Issuer = "Let's Encrypt"
b.Class = "DV"
match, complete, detail := ComparePaths(a, b)
if match || !complete {
t.Errorf("got (%v, %v), want (false, true)", match, complete)
}
if !strings.Contains(detail, "issuer") || !strings.Contains(detail, "validation") {
t.Errorf("detail should name issuer and validation diffs, got %q", detail)
}
}
func TestComparePathsFingerprint(t *testing.T) {
a, b := pathDetails()
b.Fingerprint = strings.Repeat("b", 64)
if match, complete, _ := ComparePaths(a, b); !complete || match {
t.Error("different leaf fingerprints must mismatch")
}
}
func TestComparePathsIncomplete(t *testing.T) {
a, _ := pathDetails()
if match, complete, _ := ComparePaths(a, nil); complete || match {
t.Error("missing path must be incomplete, never a mismatch")
}
if match, complete, _ := ComparePaths(nil, nil); complete || match {
t.Error("both missing must be incomplete")
}
}

83
internal/dns/asn.go Normal file
View file

@ -0,0 +1,83 @@
// 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
// Autonomous System attribution for resolution-path IPs via Team Cymru's
// DNS-based origin service. A system-resolved IP living in a different
// AS than the public-resolved IP means the two paths terminate in
// unrelated infrastructure — the network-level corroboration of rogue
// redirection.
//
// The query goes through the pinned public resolver, never the system
// resolver: a poisoned local resolver must not get to attest to its own
// redirection. IPv4 only for v1 (origin6 nibble reversal deferred).
// Lookups fail open: unanswered queries simply leave the ASN unknown.
package dns
import (
"context"
"fmt"
"net"
"strings"
"time"
)
// ASNInfo is the origin attribution for one IP.
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"
}
// ASNQueryName maps an IPv4 address to its Team Cymru origin query name,
// e.g. 8.8.8.8 -> 8.8.8.8.origin.asn.cymru.com. IPv6 reports false.
func ASNQueryName(ip net.IP) (string, bool) {
v4 := ip.To4()
if v4 == nil {
return "", false
}
return fmt.Sprintf("%d.%d.%d.%d.origin.asn.cymru.com", v4[3], v4[2], v4[1], v4[0]), true
}
// LookupASN attributes ip to its origin AS via Team Cymru, queried
// through the pinned 1.1.1.1 resolver. Fail-open: ok is false on any
// error, timeout, or unparsable answer — never an error return.
func LookupASN(ctx context.Context, ip net.IP, timeout time.Duration) (ASNInfo, bool) {
name, ok := ASNQueryName(ip)
if !ok {
return ASNInfo{}, false
}
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
txts, err := pinnedResolver("1.1.1.1:53").LookupTXT(callCtx, name)
if err != nil || len(txts) == 0 {
return ASNInfo{}, false
}
return ParseCymruTXT(txts[0])
}
// ParseCymruTXT parses one Team Cymru origin answer of the form
// "15169 | 8.8.8.0/24 | US | arin | 1992-12-01". Pure function; safe to
// unit test.
func ParseCymruTXT(txt string) (ASNInfo, bool) {
parts := strings.Split(txt, "|")
if len(parts) < 3 {
return ASNInfo{}, false
}
info := ASNInfo{
ASN: strings.TrimSpace(parts[0]),
Prefix: strings.TrimSpace(parts[1]),
CC: strings.TrimSpace(parts[2]),
}
if info.ASN == "" {
return ASNInfo{}, false
}
return info, true
}
// ASNMatch reports whether two attributions share an origin AS.
// Unknown sides never match positively: both must be known and equal.
func ASNMatch(a, b ASNInfo, aOK, bOK bool) bool {
return aOK && bOK && a.ASN == b.ASN
}

49
internal/dns/asn_test.go Normal file
View file

@ -0,0 +1,49 @@
// 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 dns
import (
"net"
"testing"
)
func TestASNQueryName(t *testing.T) {
name, ok := ASNQueryName(net.ParseIP("8.8.8.8"))
if !ok || name != "8.8.8.8.origin.asn.cymru.com" {
t.Errorf("got (%q, %v)", name, ok)
}
if _, ok := ASNQueryName(net.ParseIP("::1")); ok {
t.Error("IPv6 must report false (v1 is IPv4-only)")
}
}
func TestParseCymruTXT(t *testing.T) {
info, ok := ParseCymruTXT("15169 | 8.8.8.0/24 | US | arin | 1992-12-01")
if !ok || info.ASN != "15169" || info.Prefix != "8.8.8.0/24" || info.CC != "US" {
t.Errorf("unexpected parse: %+v, %v", info, ok)
}
if _, ok := ParseCymruTXT("garbage without pipes"); ok {
t.Error("malformed TXT must fail")
}
if _, ok := ParseCymruTXT(" | 8.8.8.0/24 | US"); ok {
t.Error("empty ASN must fail")
}
}
func TestASNMatch(t *testing.T) {
a := ASNInfo{ASN: "15169"}
b := ASNInfo{ASN: "15169"}
if !ASNMatch(a, b, true, true) {
t.Error("equal known ASNs must match")
}
c := ASNInfo{ASN: "13335"}
if ASNMatch(a, c, true, true) {
t.Error("different ASNs must not match")
}
if ASNMatch(a, b, false, true) {
t.Error("unknown side must never match")
}
}

63
internal/dns/hosts.go Normal file
View file

@ -0,0 +1,63 @@
// 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
// Static hosts-file override detection: a hardcoded mapping for the
// target or suspect domain in the OS hosts database short-circuits DNS
// entirely, so no resolver cross-check can see it. Checked once per
// domain in pre-flight; unreadable or missing files fail open (skip
// silently) — minimal containers often ship without one.
package dns
import (
"os"
"runtime"
"strings"
)
// HostsPath returns the OS static network map database location.
func HostsPath() string {
if runtime.GOOS == "windows" {
return `C:\Windows\System32\drivers\etc\hosts`
}
return "/etc/hosts"
}
// CheckHostsOverride reports whether domain has a hardcoded static
// mapping in the OS hosts file, and the mapped IP when it does.
// Fail-open: any read or parse problem means no hit, never an error.
func CheckHostsOverride(domain string) (ip string, ok bool) {
raw, err := os.ReadFile(HostsPath())
if err != nil {
return "", false
}
return ParseHostsFile(string(raw), domain)
}
// ParseHostsFile scans hosts-file content for a static mapping of
// domain. Pure function; safe to unit test. Comment lines and inline
// comments are ignored, names match case-insensitively, and the first
// mapping wins (matching OS resolver precedence).
func ParseHostsFile(content, domain string) (string, bool) {
domain = strings.ToLower(strings.TrimSpace(domain))
domain = strings.TrimSuffix(domain, ".")
if domain == "" {
return "", false
}
for _, line := range strings.Split(content, "\n") {
if i := strings.Index(line, "#"); i >= 0 {
line = line[:i]
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
for _, name := range fields[1:] {
if strings.EqualFold(strings.TrimSuffix(name, "."), domain) {
return fields[0], true
}
}
}
return "", false
}

View file

@ -0,0 +1,37 @@
// 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 dns
import "testing"
func TestParseHostsFileHit(t *testing.T) {
content := "# comment line\n127.0.0.1 localhost\n93.184.216.34 Evil.COM www.evil.com # inline\n"
ip, ok := ParseHostsFile(content, "evil.com")
if !ok || ip != "93.184.216.34" {
t.Errorf("got (%q, %v), want (93.184.216.34, true)", ip, ok)
}
// Case-insensitive, second name on the line.
if _, ok := ParseHostsFile(content, "WWW.EVIL.COM"); !ok {
t.Error("expected hit for second hostname on mapping line")
}
}
func TestParseHostsFileMiss(t *testing.T) {
content := "127.0.0.1 localhost\n#93.184.216.34 commented.com\n"
if _, ok := ParseHostsFile(content, "commented.com"); ok {
t.Error("commented mapping must not match")
}
if _, ok := ParseHostsFile(content, "absent.com"); ok {
t.Error("absent domain must not match")
}
if _, ok := ParseHostsFile(content, ""); ok {
t.Error("empty domain must not match")
}
// Trailing-dot query normalizes.
if _, ok := ParseHostsFile("1.2.3.4 dot.com\n", "dot.com."); !ok {
t.Error("expected trailing-dot tolerant match")
}
}

View file

@ -65,9 +65,10 @@ const (
)
type resolverOutcome struct {
name string
kind outcomeKind
ns []string
name string
kind outcomeKind
ns []string
addrs []net.IP
}
// pinnedResolver dials one DNS server directly, bypassing resolv.conf.
@ -82,10 +83,18 @@ func pinnedResolver(addr string) *net.Resolver {
}
// DomainCheck is one domain's full probe: system liveness for gating,
// public liveness for suppression attribution, and the aggregate verdict.
// public liveness for suppression attribution, the aggregate verdict, and
// the per-path addresses backing rogue-redirection analysis. Only the
// first live A record per side is retained: raw addresses are never
// compared for scoring (CDN/GeoDNS), they only select which endpoint the
// per-path TLS and ASN probes interrogate.
type DomainCheck struct {
SystemLive bool
PublicLive bool
SystemAddrs []net.IP
PublicAddrs []net.IP
HostsHit bool
HostsIP string
Consistency Consistency
}
@ -111,6 +120,7 @@ func CheckDomain(ctx context.Context, domain string, timeout time.Duration) Doma
addrs, err := resolvers[i].LookupIP(callCtx, "ip4", domain)
out := resolverOutcome{name: names[i], kind: classifyOutcome(addrs, err)}
if out.kind == outcomeLive {
out.addrs = append([]net.IP(nil), addrs...)
if nss, nsErr := resolvers[i].LookupNS(callCtx, domain); nsErr == nil {
for _, ns := range nss {
out.ns = append(out.ns, normalizeNS(ns.Host))
@ -126,13 +136,25 @@ func CheckDomain(ctx context.Context, domain string, timeout time.Duration) Doma
for _, o := range outcomes {
if o.name == "system" && o.kind == outcomeLive {
dc.SystemLive = true
dc.SystemAddrs = append([]net.IP(nil), o.addrs...)
}
if o.name != "system" && o.kind == outcomeLive {
dc.PublicLive = true
// First live public resolver wins (1.1.1.1 order): the
// per-path probes need one representative public endpoint.
if len(dc.PublicAddrs) == 0 {
dc.PublicAddrs = append([]net.IP(nil), o.addrs...)
}
}
}
kind, detail := detectSplit(domain, outcomes)
dc.Consistency = Consistency{Checked: true, Tampered: kind == SplitSuppression || kind == SplitNS, Kind: kind, Detail: detail}
// Static hosts-file override short-circuits every resolver, so it is
// checked here in pre-flight alongside the resolver probes.
if ip, ok := CheckHostsOverride(domain); ok {
dc.HostsHit = true
dc.HostsIP = ip
}
return dc
}

View file

@ -13,9 +13,9 @@ import (
"fmt"
"strings"
"isthislegit/internal/cert"
"isthislegit/internal/rdap"
"isthislegit/internal/score"
"phisherslapper/internal/cert"
"phisherslapper/internal/rdap"
"phisherslapper/internal/score"
)
// Data bundles everything needed to render a triage result.
@ -37,7 +37,7 @@ type Data struct {
// Scale prints the scoring reference table (no lookups performed).
func Scale(version string) string {
return fmt.Sprintf(`isthislegit? [v%s] — Scoring Scale (max total: 200)
return fmt.Sprintf(`phisherslapper [v%s] — Scoring Scale (max total: 330)
Signals:
+-----------------------------------------------+--------+
@ -55,6 +55,10 @@ Signals:
| Registrar mismatch | +30 |
| Same-class issuer difference (display only) | +0 |
| Unknown registrar (best-effort field) | +0 |
| Rogue TLS: per-path cert divergence (suspect) | +50 |
| Rogue ASN: per-path AS split (suspect) | +30 |
| Hosts-file override (suspect) | +50 |
| Rogue signals target-side/inconclusive (info) | +0 |
+-----------------------------------------------+--------+
Verdicts:
@ -72,7 +76,7 @@ Verdicts:
// Human renders the triage report.
func Human(d Data) string {
var b strings.Builder
fmt.Fprintf(&b, "isthislegit? [v%s]\n", d.Version)
fmt.Fprintf(&b, "phisherslapper [v%s]\n", d.Version)
b.WriteString("Domain Impersonation & OSINT Triage Tool\n\n")
fmt.Fprintf(&b, "[*] Comparing: %s (Target) <---> %s (Suspect)\n\n", d.TargetDomain, d.SuspectDomain)
fmt.Fprintf(&b, "[+] Target Domain: %s\n", d.TargetDomain)
@ -118,13 +122,37 @@ func JSON(d Data) string {
CertSANs string `json:"cert_sans"`
AgeDays *int `json:"age_days"`
}
type paths struct {
SystemIP string `json:"system_ip"`
PublicIP string `json:"public_ip"`
CertChecked bool `json:"per_path_tls_checked"`
CertComplete bool `json:"per_path_tls_complete"`
CertMatch bool `json:"per_path_tls_match"`
CertDetail string `json:"per_path_tls_detail"`
SystemASN string `json:"system_asn"`
PublicASN string `json:"public_asn"`
ASNChecked bool `json:"asn_checked"`
ASNMatch bool `json:"asn_match"`
HostsHit bool `json:"hosts_override"`
HostsIP string `json:"hosts_ip"`
}
pathOf := func(dd score.DomainData) paths {
return paths{
SystemIP: dd.SysIP, PublicIP: dd.PubIP,
CertChecked: dd.PathChecked, CertComplete: dd.PathComplete,
CertMatch: dd.PathMatch, CertDetail: dd.PathDetail,
SystemASN: dd.SysASN, PublicASN: dd.PubASN,
ASNChecked: dd.ASNChecked, ASNMatch: dd.ASNMatch,
HostsHit: dd.HostsHit, HostsIP: dd.HostsIP,
}
}
var suspectAge *int
if d.Suspect.AgeKnown {
v := d.Suspect.AgeDays
suspectAge = &v
}
out := map[string]any{
"tool": "isthislegit",
"tool": "phisherslapper",
"version": d.Version,
"target": side{
Domain: d.TargetDomain, CreationDate: d.Target.Date,
@ -137,9 +165,11 @@ func JSON(d Data) string {
"cert_class": d.Suspect.Class, "cert_sans": d.Suspect.SANs,
"age_days": suspectAge,
},
"risk_score": d.Score,
"findings": d.Findings,
"verdict": d.Verdict,
"risk_score": d.Score,
"findings": d.Findings,
"verdict": d.Verdict,
"target_paths": pathOf(d.Target),
"suspect_paths": pathOf(d.Suspect),
}
if out["findings"] == nil {
out["findings"] = []string{}
@ -213,6 +243,8 @@ func Verbose(d Data) string {
writeRDAP(d.SuspectDomain, d.SuspectRDAP)
writeDNS(&b, "Target", d.TargetDomain, d.Target.DNSTampered, d.Target.DNSDetail)
writeDNS(&b, "Suspect", d.SuspectDomain, d.Suspect.DNSTampered, d.Suspect.DNSDetail)
writePaths(&b, "Target", d.TargetDomain, d.Target)
writePaths(&b, "Suspect", d.SuspectDomain, d.Suspect)
return b.String()
}
@ -230,6 +262,54 @@ func writeDNS(b *strings.Builder, label, domain string, tampered bool, detail st
fmt.Fprintf(b, " %s\n\n", status)
}
// writePaths renders the rogue-redirection path intel for one domain:
// per-path endpoints, per-path TLS agreement, ASN attribution, and the
// hosts-file override state.
func writePaths(b *strings.Builder, label, domain string, d score.DomainData) {
fmt.Fprintf(b, "--- Resolution paths (%s):\n", domain)
if !d.PathChecked && !d.HostsHit {
fmt.Fprintf(b, " no per-path divergence probed (%s)\n\n", label)
return
}
sysIP, pubIP := d.SysIP, d.PubIP
if sysIP == "" {
sysIP = "—"
}
if pubIP == "" {
pubIP = "—"
}
fmt.Fprintf(b, " local endpoint: %s | public endpoint: %s\n", sysIP, pubIP)
switch {
case d.PathComplete && d.PathMatch:
b.WriteString(" per-path TLS: identities agree\n")
case d.PathComplete:
fmt.Fprintf(b, " per-path TLS: DIVERGENT (%s)\n", d.PathDetail)
case d.PathChecked:
b.WriteString(" per-path TLS: inconclusive (one endpoint refused port 443)\n")
}
if d.ASNChecked {
status := "agree"
if !d.ASNMatch {
status = "SPLIT"
}
fmt.Fprintf(b, " origin AS: local %s vs public %s [%s]\n", asnOrUnknown(d.SysASN), asnOrUnknown(d.PubASN), status)
} else {
b.WriteString(" origin AS: unattributed (Team Cymru query unanswered)\n")
}
if d.HostsHit {
fmt.Fprintf(b, " hosts override: STATIC MAPPING %s -> %s\n\n", d.HostsIP, domain)
} else {
b.WriteString(" hosts override: none\n\n")
}
}
func asnOrUnknown(s string) string {
if s == "" {
return "unknown"
}
return s
}
func classOf(c *cert.Details) string {
if c == nil || c.Class == "" {
return "Unknown"

View file

@ -0,0 +1,121 @@
// 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 (
"strings"
"testing"
)
// rogueBase returns an old, same-registrar, same-issuer DV pair: zero
// points before rogue-redirection signals are layered on.
func rogueBase() (DomainData, DomainData) {
target := DomainData{Domain: "a.com", Date: "2000-01-01", Registrar: "Same Inc.", Issuer: "DigiCert Inc", Class: "DV", AgeDays: 9000, AgeKnown: true}
suspect := DomainData{Domain: "b.com", Date: "2001-01-01", Registrar: "Same Inc.", Issuer: "DigiCert Inc", Class: "DV", AgeDays: 8000, AgeKnown: true}
return target, suspect
}
func findingsContain(r Result, sub string) bool {
for _, f := range r.Findings {
if strings.Contains(f, sub) {
return true
}
}
return false
}
func TestScoreHostsHit(t *testing.T) {
target, suspect := rogueBase()
suspect.HostsHit = true
suspect.HostsIP = "127.0.0.1"
r := Score(target, suspect)
if r.Score != 50 {
t.Errorf("Score = %d, want 50 (hosts override)", r.Score)
}
if !findingsContain(r, "HIJACK") {
t.Errorf("expected HIJACK finding, got %v", r.Findings)
}
}
func TestScorePathDivergence(t *testing.T) {
target, suspect := rogueBase()
suspect.PathChecked, suspect.PathComplete = true, true
suspect.PathMatch = false
suspect.PathDetail = `issuer "A" vs "B"`
suspect.SysIP, suspect.PubIP = "1.2.3.4", "5.6.7.8"
r := Score(target, suspect)
if r.Score != 50 {
t.Errorf("Score = %d, want 50 (per-path TLS divergence)", r.Score)
}
if !findingsContain(r, "REDIRECTION") {
t.Errorf("expected REDIRECTION finding, got %v", r.Findings)
}
}
func TestScoreASNSplit(t *testing.T) {
target, suspect := rogueBase()
suspect.ASNChecked = true
suspect.ASNMatch = false
suspect.SysASN, suspect.PubASN = "AS666", "AS15169"
r := Score(target, suspect)
if r.Score != 30 {
t.Errorf("Score = %d, want 30 (ASN split)", r.Score)
}
if !findingsContain(r, "autonomous systems") {
t.Errorf("expected ASN finding, got %v", r.Findings)
}
}
func TestScoreRogueCombined(t *testing.T) {
target, suspect := rogueBase()
suspect.HostsHit, suspect.HostsIP = true, "127.0.0.1"
suspect.PathChecked, suspect.PathComplete, suspect.PathMatch = true, true, false
suspect.PathDetail = "leaf fingerprint ab vs cd"
suspect.ASNChecked, suspect.ASNMatch = true, false
suspect.SysASN, suspect.PubASN = "AS666", "AS15169"
r := Score(target, suspect)
if r.Score != 130 {
t.Errorf("Score = %d, want 130 (50 hosts + 50 TLS + 30 ASN)", r.Score)
}
if got := Verdict(r.Score); got != "LIKELY PHISHING / IMPERSONATION ATTEMPT" {
t.Errorf("Verdict = %q", got)
}
}
func TestScoreRogueTargetSideDisplayOnly(t *testing.T) {
target, suspect := rogueBase()
target.HostsHit, target.HostsIP = true, "127.0.0.1"
target.PathChecked, target.PathComplete, target.PathMatch = true, true, false
target.PathDetail = "issuer \"A\" vs \"B\""
target.ASNChecked, target.ASNMatch = true, false
r := Score(target, suspect)
if r.Score != 0 {
t.Errorf("Score = %d, want 0 (target-side rogue signals never score)", r.Score)
}
if !findingsContain(r, "NOTE") {
t.Errorf("expected display-only NOTEs, got %v", r.Findings)
}
for _, f := range r.Findings {
for _, banned := range []string{"HIJACK", "REDIRECTION", "MISMATCH: Suspect"} {
if strings.Contains(f, banned) {
t.Errorf("target-side must not emit scoring finding, got %q", f)
}
}
}
}
func TestScorePathInconclusive(t *testing.T) {
target, suspect := rogueBase()
suspect.PathChecked = true
suspect.SysIP, suspect.PubIP = "1.2.3.4", "5.6.7.8"
r := Score(target, suspect)
if r.Score != 0 {
t.Errorf("Score = %d, want 0 (inconclusive path comparison)", r.Score)
}
if !findingsContain(r, "inconclusive") {
t.Errorf("expected inconclusive NOTE, got %v", r.Findings)
}
}

View file

@ -13,7 +13,7 @@ package score
import "fmt"
import "strings"
import "isthislegit/internal/dns"
import "phisherslapper/internal/dns"
// DomainData holds the collected triage fields for one domain.
type DomainData struct {
@ -28,6 +28,22 @@ type DomainData struct {
DNSTampered bool // suppression/NS split (scores on suspect)
DNSSplit dns.SplitKind // split direction (horizon never scores)
DNSDetail string // human-readable split description
// Rogue-redirection path intel: what the system-resolved endpoint
// serves versus the public-resolved endpoint.
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
}
// Result is the outcome of scoring a target/suspect pair.
@ -93,6 +109,7 @@ func Score(target, suspect DomainData) Result {
if suspect.DNSDetail != target.DNSDetail {
noteDNS(&r, suspect, "suspect")
}
notePathIntel(&r, target, "target")
r.Verdict = Verdict(r.Score)
return r
}
@ -145,6 +162,29 @@ func Score(target, suspect DomainData) Result {
}
noteDNS(&r, target, "target")
// Rogue redirection: local-path vs public-path divergence on the
// suspect side. A hosts-file override short-circuits every resolver
// (+50, suppression tier); divergent per-path TLS identities prove
// the local answer serves a different endpoint (+50); termination
// in different autonomous systems corroborates at network level
// (+30). Target-side and inconclusive probes stay display-only.
if suspect.HostsHit {
r.Score += 50
r.Findings = append(r.Findings, fmt.Sprintf("HIJACK: Suspect has a hardcoded static mapping in the OS hosts file (%s -> %s); local resolution is overridden before DNS is ever consulted.", suspect.HostsIP, suspect.Domain))
}
if suspect.PathComplete && !suspect.PathMatch {
r.Score += 50
r.Findings = append(r.Findings, fmt.Sprintf("REDIRECTION: Suspect serves divergent TLS identities per resolution path (local %s vs public %s): %s.", suspect.SysIP, suspect.PubIP, suspect.PathDetail))
}
if suspect.PathChecked && !suspect.PathComplete {
r.Findings = append(r.Findings, fmt.Sprintf("NOTE: Per-path TLS comparison for suspect inconclusive (local %s vs public %s); one endpoint refused port 443.", suspect.SysIP, suspect.PubIP))
}
if suspect.ASNChecked && !suspect.ASNMatch {
r.Score += 30
r.Findings = append(r.Findings, fmt.Sprintf("MISMATCH: Suspect resolution paths terminate in different autonomous systems (local %s %s vs public %s %s).", suspect.SysASN, parenthesize(suspect.SysASNNet), suspect.PubASN, parenthesize(suspect.PubASNNet)))
}
notePathIntel(&r, target, "target")
r.Verdict = Verdict(r.Score)
return r
}
@ -164,6 +204,30 @@ func noteDNS(r *Result, d DomainData, role string) {
}
}
// notePathIntel appends display-only rogue-redirection findings for one
// side: hosts overrides, per-path TLS divergence, and ASN splits are
// observer-side suspicion without points. No points.
func notePathIntel(r *Result, d DomainData, role string) {
if d.HostsHit {
r.Findings = append(r.Findings, fmt.Sprintf("NOTE: %s has a static hosts-file mapping (%s -> %s); local resolution is overridden.", role, d.HostsIP, d.Domain))
}
if d.PathComplete && !d.PathMatch {
r.Findings = append(r.Findings, fmt.Sprintf("NOTE: %s serves divergent TLS identities per resolution path (local %s vs public %s): %s.", role, d.SysIP, d.PubIP, d.PathDetail))
}
if d.ASNChecked && !d.ASNMatch {
r.Findings = append(r.Findings, fmt.Sprintf("NOTE: %s resolution paths terminate in different autonomous systems (local %s vs public %s).", role, d.SysASN, d.PubASN))
}
}
// parenthesize wraps non-empty detail for finding text.
func parenthesize(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
return "(" + s + ")"
}
// registrarMismatch reports a case-insensitive registrar difference,
// skipped when either side is unknown.
func registrarMismatch(target, suspect DomainData) bool {

View file

@ -10,7 +10,7 @@ import (
"testing"
"time"
"isthislegit/internal/dns"
"phisherslapper/internal/dns"
)
func TestScorePhishing(t *testing.T) {