From 2bda98917aa7b59ba9b2923aa6b1f21cebefb91a Mon Sep 17 00:00:00 2001
From: markmental
Date: Wed, 9 Sep 2026 12:26:51 -0400
Subject: [PATCH] Rename to phisherslapper, better detect Rogue DNS Redirection
---
.gitignore | 4 +-
README.md | 47 +++++---
cmd/{isthislegit => phisherslapper}/main.go | 116 ++++++++++++++++---
compile.go | 8 +-
docs/cert.html | 2 +-
docs/dns.html | 2 +-
docs/index.html | 2 +-
docs/main.html | 19 +--
docs/nav.html | 4 +-
docs/overview.html | 36 ++++--
docs/rdap.html | 2 +-
docs/report.html | 2 +-
docs/score.html | 2 +-
go.mod | 2 +-
internal/cert/cert.go | 120 ++++++++++++++++---
internal/cert/paths_test.go | 58 ++++++++++
internal/dns/asn.go | 83 ++++++++++++++
internal/dns/asn_test.go | 49 ++++++++
internal/dns/hosts.go | 63 ++++++++++
internal/dns/hosts_test.go | 37 ++++++
internal/dns/resolvers.go | 30 ++++-
internal/report/report.go | 98 ++++++++++++++--
internal/score/rogue_test.go | 121 ++++++++++++++++++++
internal/score/score.go | 66 ++++++++++-
internal/score/score_test.go | 2 +-
25 files changed, 877 insertions(+), 98 deletions(-)
rename cmd/{isthislegit => phisherslapper}/main.go (74%)
create mode 100644 internal/cert/paths_test.go
create mode 100644 internal/dns/asn.go
create mode 100644 internal/dns/asn_test.go
create mode 100644 internal/dns/hosts.go
create mode 100644 internal/dns/hosts_test.go
create mode 100644 internal/score/rogue_test.go
diff --git a/.gitignore b/.gitignore
index 23e3d69..aa9e0b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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*
diff --git a/README.md b/README.md
index 13aefd4..6befa7b 100644
--- a/README.md
+++ b/README.md
@@ -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--` (with a `vGOARM`
-suffix when applicable, e.g. `isthislegit-linux-armv7`, and `.exe` on
+Output binaries are named `phisherslapper--` (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.
diff --git a/cmd/isthislegit/main.go b/cmd/phisherslapper/main.go
similarity index 74%
rename from cmd/isthislegit/main.go
rename to cmd/phisherslapper/main.go
index c849972..ae21e7e 100644
--- a/cmd/isthislegit/main.go
+++ b/cmd/phisherslapper/main.go
@@ -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]
+ phisherslapper [OPTIONS]
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
diff --git a/compile.go b/compile.go
index 3c35d68..9ec5c5a 100644
--- a/compile.go
+++ b/compile.go
@@ -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(),
diff --git a/docs/cert.html b/docs/cert.html
index 30e844a..04097bb 100644
--- a/docs/cert.html
+++ b/docs/cert.html
@@ -1,7 +1,7 @@
-isthislegit Developer Docs - Package cert
+phisherslapper Developer Docs - Package cert
Package cert
diff --git a/docs/dns.html b/docs/dns.html
index 7874423..4da8db7 100644
--- a/docs/dns.html
+++ b/docs/dns.html
@@ -1,7 +1,7 @@
-isthislegit Developer Docs - Package dns
+phisherslapper Developer Docs - Package dns
Package dns
diff --git a/docs/index.html b/docs/index.html
index e6c26e5..8480f27 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1,7 +1,7 @@
-isthislegit Developer Docs
+phisherslapper Developer Docs
Licensed under the GNU General Public License v3 - Mark Robillard Jr (MARKMENTAL)
diff --git a/docs/nav.html b/docs/nav.html
index f160814..aab189d 100644
--- a/docs/nav.html
+++ b/docs/nav.html
@@ -1,10 +1,10 @@
-isthislegit Docs - Navigation
+phisherslapper Docs - Navigation
-isthislegit?
+phisherslapper
Overview
Packages
diff --git a/docs/overview.html b/docs/overview.html
index f3d3bcc..26ac43e 100644
--- a/docs/overview.html
+++ b/docs/overview.html
@@ -1,10 +1,10 @@
-isthislegit Developer Docs - Overview
+phisherslapper Developer Docs - Overview
-isthislegit? [v1.01]
+phisherslapper [v1.01]
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
golang.org/x/sync only.
Build and Run
-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
go test ./... |
Flags
@@ -36,10 +36,10 @@ DNS, TLS, and RDAP call carries a 5 second deadline (adjustable with
--timeout) 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).
-parse args --> DNS pre-flight (target + suspect, parallel)
- --> fetch target = RDAP + TLS in parallel
- --> fetch suspect = RDAP + TLS in parallel
- --> fail-closed verify --> score --> render |
+parse args --> DNS pre-flight (target + suspect, parallel; keeps per-path IPs, checks hosts file)
+ --> fetch target = RDAP + TLS + per-path TLS + ASN in parallel
+ --> fetch suspect = RDAP + TLS + per-path TLS + ASN in parallel
+ --> fail-closed verify --> score --> render |
Exit codes: 0 on success (including --scale and
--help), 1 on bad flags, invalid domains, DNS failure, or
failed data-quality verification.
@@ -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.
+Rogue Redirection
+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
+(origin.asn.cymru.com 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.
+
Embedded Systems
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 -k/--insecure as a last resort.
|
| 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 |
diff --git a/docs/rdap.html b/docs/rdap.html
index 2633c78..1be01db 100644
--- a/docs/rdap.html
+++ b/docs/rdap.html
@@ -1,7 +1,7 @@
-isthislegit Developer Docs - Package rdap
+phisherslapper Developer Docs - Package rdap
Package rdap
diff --git a/docs/report.html b/docs/report.html
index 2a276b4..ececee8 100644
--- a/docs/report.html
+++ b/docs/report.html
@@ -1,7 +1,7 @@
-isthislegit Developer Docs - Package report
+phisherslapper Developer Docs - Package report
Package report
diff --git a/docs/score.html b/docs/score.html
index 32741ac..f05547a 100644
--- a/docs/score.html
+++ b/docs/score.html
@@ -1,7 +1,7 @@
-isthislegit Developer Docs - Package score
+phisherslapper Developer Docs - Package score
Package score
diff --git a/go.mod b/go.mod
index aeee26c..dfd301c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module isthislegit
+module phisherslapper
go 1.24
diff --git a/internal/cert/cert.go b/internal/cert/cert.go
index 3040aaf..7ae3b60 100644
--- a/internal/cert/cert.go
+++ b/internal/cert/cert.go
@@ -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
+}
diff --git a/internal/cert/paths_test.go b/internal/cert/paths_test.go
new file mode 100644
index 0000000..9303587
--- /dev/null
+++ b/internal/cert/paths_test.go
@@ -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")
+ }
+}
diff --git a/internal/dns/asn.go b/internal/dns/asn.go
new file mode 100644
index 0000000..8739318
--- /dev/null
+++ b/internal/dns/asn.go
@@ -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
+}
diff --git a/internal/dns/asn_test.go b/internal/dns/asn_test.go
new file mode 100644
index 0000000..748181f
--- /dev/null
+++ b/internal/dns/asn_test.go
@@ -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")
+ }
+}
diff --git a/internal/dns/hosts.go b/internal/dns/hosts.go
new file mode 100644
index 0000000..f59d8df
--- /dev/null
+++ b/internal/dns/hosts.go
@@ -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
+}
diff --git a/internal/dns/hosts_test.go b/internal/dns/hosts_test.go
new file mode 100644
index 0000000..8199699
--- /dev/null
+++ b/internal/dns/hosts_test.go
@@ -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")
+ }
+}
diff --git a/internal/dns/resolvers.go b/internal/dns/resolvers.go
index 954a6d1..0369abc 100644
--- a/internal/dns/resolvers.go
+++ b/internal/dns/resolvers.go
@@ -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
}
diff --git a/internal/report/report.go b/internal/report/report.go
index e8206c5..e1362bf 100644
--- a/internal/report/report.go
+++ b/internal/report/report.go
@@ -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"
diff --git a/internal/score/rogue_test.go b/internal/score/rogue_test.go
new file mode 100644
index 0000000..caf0e3c
--- /dev/null
+++ b/internal/score/rogue_test.go
@@ -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)
+ }
+}
diff --git a/internal/score/score.go b/internal/score/score.go
index 7464d54..809c886 100644
--- a/internal/score/score.go
+++ b/internal/score/score.go
@@ -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 {
diff --git a/internal/score/score_test.go b/internal/score/score_test.go
index 471307d..f285bbe 100644
--- a/internal/score/score_test.go
+++ b/internal/score/score_test.go
@@ -10,7 +10,7 @@ import (
"testing"
"time"
- "isthislegit/internal/dns"
+ "phisherslapper/internal/dns"
)
func TestScorePhishing(t *testing.T) {