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 diff --git a/docs/main.html b/docs/main.html index a7590f9..8cc9852 100644 --- a/docs/main.html +++ b/docs/main.html @@ -1,16 +1,16 @@ -isthislegit Developer Docs - Command main +phisherslapper Developer Docs - Command main

Command main

CLI entry point and fetch orchestration. Source: -cmd/isthislegit/main.go. Package main exposes no exported +cmd/phisherslapper/main.go. Package main exposes no exported symbols; this page documents behavior.

Arguments

-

Syntax: isthislegit [FLAGS] <legit_domain> +

Syntax: phisherslapper [FLAGS] <legit_domain> <suspect_domain>. Flags -v/--verbose, -j/--json, -k/--insecure (skips RDAP TLS verification with a warning), --timeout=DUR (Go duration, default 5s), @@ -35,11 +35,14 @@ include the underlying fetch cause(s).

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. +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.
  • Per-domain fetch in parallel: RDAP (rdap.Query) plus TLS -(cert.InspectDomain) concurrently. -Field-level failures stay open (Unknown) here.
  • +(cert.InspectDomain) 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.
  • Fail-closed gate: score.VerifyDataQuality; aborts on missing dates or TLS data for either side.
  • @@ -53,8 +56,8 @@ plus report.Verbose when verbose.

    Version

    Current version string is 1.01, printed in the banner, JSON output, and --scale header. Build target: -CGO_ENABLED=0 go build -ldflags="-s -w" -o isthislegit -./cmd/isthislegit.

    +CGO_ENABLED=0 go build -ldflags="-s -w" -o phisherslapper +./cmd/phisherslapper.


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