Rename to phisherslapper, better detect Rogue DNS Redirection
This commit is contained in:
parent
b7dec339f7
commit
2bda98917a
25 changed files with 877 additions and 98 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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*
|
||||
|
|
|
|||
47
README.md
47
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-<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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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] <legit_domain>
|
||||
<p>Syntax: <tt>phisherslapper [FLAGS] <legit_domain>
|
||||
<suspect_domain></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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 --> DNS pre-flight (target + suspect, parallel)
|
||||
--> fetch target = RDAP + TLS in parallel
|
||||
--> fetch suspect = RDAP + TLS in parallel
|
||||
--> fail-closed verify --> score --> render</pre></td></tr></table>
|
||||
<table border="0" cellpadding="8" width="100%"><tr><td bgcolor="#073642"><pre>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</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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module isthislegit
|
||||
module phisherslapper
|
||||
|
||||
go 1.24
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
58
internal/cert/paths_test.go
Normal file
58
internal/cert/paths_test.go
Normal 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
83
internal/dns/asn.go
Normal 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
49
internal/dns/asn_test.go
Normal 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
63
internal/dns/hosts.go
Normal 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
|
||||
}
|
||||
37
internal/dns/hosts_test.go
Normal file
37
internal/dns/hosts_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
121
internal/score/rogue_test.go
Normal file
121
internal/score/rogue_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"isthislegit/internal/dns"
|
||||
"phisherslapper/internal/dns"
|
||||
)
|
||||
|
||||
func TestScorePhishing(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue