From ced950004a4c37dfb4cbf1b474739f0a3b3bb017 Mon Sep 17 00:00:00 2001 From: markmental Date: Tue, 8 Sep 2026 20:14:46 -0400 Subject: [PATCH] Adds option/arg to allow minimal devices to make TLS insecure requests if needed, also adds compile.go build driver --- README.md | 67 +++++++++++- cmd/isthislegit/main.go | 91 +++++++++++++---- compile.go | 219 ++++++++++++++++++++++++++++++++++++++++ docs/dns.html | 5 +- docs/main.html | 9 +- docs/overview.html | 16 ++- internal/dns/dns.go | 4 +- 7 files changed, 378 insertions(+), 33 deletions(-) create mode 100644 compile.go diff --git a/README.md b/README.md index 4050c3e..3a5971b 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,51 @@ Run the test suite with: 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 +`CGO_ENABLED=0`, so every target builds without a cross C toolchain: + +```bash +go run ./compile.go --platform=linux --arch=amd64 +``` + +| Flag | Default | Description | +|--------------|---------|----------------------------------------------------------| +| `--platform` | `linux` | Target OS (see vocabulary below). | +| `--arch` | `amd64` | Target arch (see vocabulary below). | +| `--static` | `true` | `true` for static (`CGO_ENABLED=0`), `false` for dynamic (`CGO_ENABLED=1`, host builds only unless `CC` points at a cross compiler). Both modes are stripped via `-ldflags="-s -w"`. | + +Platform vocabulary (case-insensitive): `linux`; `mac`/`macos`/`osx`/`darwin` +→ `darwin`; `windows`/`win` → `windows`; `bsd` → all three of `freebsd`, +`openbsd`, `netbsd` in one run (or name a single BSD explicitly). + +Arch vocabulary: raw `GOARCH` names plus aliases — `x64`/`x86-64` → `amd64`; +`x86`/`i386`/`32bit` → `386`; `aarch64` → `arm64`; `arm`/`armv7`/`armv6`/`armv5` +→ `arm` with matching `GOARM`. Unknown values exit 1, and combos the +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 +Windows): + +```bash +go run ./compile.go --platform=linux --arch=amd64 +go run ./compile.go --platform=windows --arch=x64 --static=false +go run ./compile.go --platform=bsd --arch=x64 +go run ./compile.go --platform=linux --arch=armv7 # embedded ARM (Miyoo Mini Plus) +``` + ## Flags | Flag | Description | |-----------------|---------------------------------------------------------------| | `-v, --verbose` | Append raw cert policy OIDs, TLS details, and RDAP JSON. | -| `-j, --json` | Output the result summary as JSON for pipeline scripting. | +| `-j, --json` | Output the result summary as JSON for scripting. | +| `-k, --insecure`| Skip TLS verification for RDAP | +| `--timeout=DUR` | Per-call deadline for DNS/TLS/RDAP (Go duration, default 5s). | | `--scale` | Print the scoring scale reference table and exit. | | `-h, --help` | Display usage and exit. | @@ -53,9 +92,27 @@ Examples: ```bash isthislegit jdsoft.com jdsoftcareers.com isthislegit -j example.com suspect-example.com +isthislegit --timeout=10s example.com suspect-example.com isthislegit --scale ``` +### Embedded systems (no CA certificates) + +`isthislegit` 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 +(`x509: certificate signed by unknown authority`). Prefer installing +`ca-certificates` and fixing the system clock where possible; otherwise use +insecure mode: + +```bash +isthislegit -k jdsoft.com jdsoftcareers.com +``` + +`--insecure` prints a warning and weakens results, so treat it as a last +resort for devices without stored CA certificates. + ## Example output ```text @@ -104,8 +161,12 @@ VERDICT: LIKELY UNRELATED / MISCONFIGURED Lookups fail closed: if RDAP dates or TLS data cannot be retrieved for either domain, the tool errors out instead of printing a verdict built on -missing data. Fetches run concurrently under a 15s global budget with 5s -per-call deadlines (3 attempts each). +missing data, and the message names the underlying cause(s) (e.g. a TLS +verification failure, which on minimal devices usually means missing CA +certificates or a wrong system clock). Fetches run concurrently under a +15s global budget with 5s per-call deadlines (3 attempts each, adjustable +via `--timeout`); `--insecure` skips RDAP TLS verification as a last +resort, with a warning. ## Project layout diff --git a/cmd/isthislegit/main.go b/cmd/isthislegit/main.go index dc91b6f..fbe3efb 100644 --- a/cmd/isthislegit/main.go +++ b/cmd/isthislegit/main.go @@ -6,9 +6,11 @@ package main import ( "context" + "crypto/tls" "fmt" "net/http" "os" + "strings" "time" "golang.org/x/sync/errgroup" @@ -22,7 +24,7 @@ import ( const version = "1.01" -const perCall = 5 * time.Second +const defaultPerCall = 5 * time.Second const globalTimeout = 15 * time.Second func usage() string { @@ -35,22 +37,28 @@ Usage: Options: -v, --verbose Output raw JSON responses from RDAP and full TLS cert details. -j, --json Output summary results in raw JSON format for scripting. + -k, --insecure Skip TLS verification for RDAP (weakens results; use only + on devices without CA certificates). Prints a warning. + --timeout=DUR Per-call deadline for DNS/TLS/RDAP (Go duration, default 5s). --scale Show the scoring scale table and exit (no domains required). -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 `, version) } type domainResult struct { - info score.DomainData - cert *cert.Details - rdap *rdap.Result + info score.DomainData + cert *cert.Details + rdap *rdap.Result + rdapErr error + certErr error } -func fetchDomain(ctx context.Context, client *http.Client, domain string, now time.Time) domainResult { +func fetchDomain(ctx context.Context, client *http.Client, domain string, now time.Time, perCall time.Duration) domainResult { res := domainResult{info: score.DomainData{Domain: domain}} var g errgroup.Group @@ -59,21 +67,17 @@ func fetchDomain(ctx context.Context, client *http.Client, domain string, now ti g.Go(func() error { r, err := rdap.Query(ctx, client, domain, perCall) - if err != nil { - return err - } - rd = r - return nil + rd, res.rdapErr = r, err + return err }) g.Go(func() error { c, err := cert.InspectDomain(ctx, domain, perCall) - if err != nil { - return err - } - cd = c - return nil + cd, res.certErr = c, err + return err }) - _ = g.Wait() // Fail-open per field; fail-closed verdict comes later. + // Fail-open per field; the root causes are kept for the + // fail-closed verdict gate below. + _ = g.Wait() res.rdap = rd res.cert = cd @@ -109,7 +113,8 @@ func fetchDomain(ctx context.Context, client *http.Client, domain string, now ti } func run() int { - var verbose, jsonOut bool + var verbose, jsonOut, insecure bool + var perCall = defaultPerCall var positionals []string args := os.Args[1:] seenDashDash := false @@ -119,11 +124,34 @@ func run() int { positionals = append(positionals, a) continue } + if strings.HasPrefix(a, "--timeout=") { + d, err := time.ParseDuration(strings.TrimPrefix(a, "--timeout=")) + if err != nil || d <= 0 { + fmt.Fprintf(os.Stderr, "[!] Error: Invalid --timeout value '%s'. Use a Go duration like 5s or 10s.\n", a) + return 1 + } + perCall = d + continue + } switch a { case "-v", "--verbose": verbose = true case "-j", "--json": jsonOut = true + case "-k", "--insecure": + insecure = true + case "--timeout": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "[!] Error: --timeout requires a value (e.g. --timeout=10s).") + return 1 + } + i++ + d, err := time.ParseDuration(args[i]) + if err != nil || d <= 0 { + fmt.Fprintf(os.Stderr, "[!] Error: Invalid --timeout value '%s'. Use a Go duration like 5s or 10s.\n", args[i]) + return 1 + } + perCall = d case "--scale": fmt.Print(report.Scale(version)) return 0 @@ -164,7 +192,7 @@ func run() int { for _, d := range []string{legit, suspect} { d := d dg.Go(func() error { - if err := dns.CheckDNS(ctx, d); err != nil { + if err := dns.CheckDNS(ctx, d, perCall); err != nil { return fmt.Errorf("domain '%s' failed DNS resolution (NXDOMAIN or offline). Aborting", d) } return nil @@ -176,22 +204,43 @@ func run() int { } client := &http.Client{} + if insecure { + fmt.Fprintln(os.Stderr, "[!] Warning: TLS verification disabled for RDAP (--insecure). Results are weaker.") + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + } now := time.Now() var target, sus domainResult var fg errgroup.Group fg.Go(func() error { - target = fetchDomain(ctx, client, legit, now) + target = fetchDomain(ctx, client, legit, now, perCall) return nil }) fg.Go(func() error { - sus = fetchDomain(ctx, client, suspect, now) + sus = fetchDomain(ctx, client, suspect, now, perCall) return nil }) _ = fg.Wait() if err := score.VerifyDataQuality(target.info, sus.info); err != nil { - fmt.Fprintf(os.Stderr, "[!] Error: %v.\n", err) + msg := fmt.Sprintf("[!] Error: %v.", err) + var causes []string + if target.rdapErr != nil { + causes = append(causes, fmt.Sprintf("target RDAP: %v", target.rdapErr)) + } + if target.certErr != nil { + causes = append(causes, fmt.Sprintf("target TLS: %v", target.certErr)) + } + if sus.rdapErr != nil { + causes = append(causes, fmt.Sprintf("suspect RDAP: %v", sus.rdapErr)) + } + if sus.certErr != nil { + causes = append(causes, fmt.Sprintf("suspect TLS: %v", sus.certErr)) + } + if len(causes) > 0 { + msg += fmt.Sprintf(" Cause(s): %s.", strings.Join(causes, "; ")) + } + fmt.Fprintln(os.Stderr, msg) return 1 } result := score.Score(target.info, sus.info) diff --git a/compile.go b/compile.go new file mode 100644 index 0000000..69c6ff3 --- /dev/null +++ b/compile.go @@ -0,0 +1,219 @@ +// Command compile is the build driver for isthislegit. It cross-compiles +// ./cmd/isthislegit for the requested platform/arch matrix: +// +// go run ./compile.go --platform=linux --arch=amd64 --static=true +// +// Static builds (the default) are CGO-free so every target compiles +// without a cross C toolchain. Dynamic builds (--static=false) enable +// CGO and link libc dynamically; they require a C compiler for the +// target (gcc for host builds, or CC set to a cross compiler). +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +// platformAliases maps friendly platform names to GOOS values. +// "bsd" fans out to freebsd, openbsd, and netbsd. +var platformAliases = map[string][]string{ + "linux": {"linux"}, + "darwin": {"darwin"}, + "mac": {"darwin"}, + "macos": {"darwin"}, + "osx": {"darwin"}, + "windows": {"windows"}, + "win": {"windows"}, + "bsd": {"freebsd", "openbsd", "netbsd"}, + "freebsd": {"freebsd"}, + "openbsd": {"openbsd"}, + "netbsd": {"netbsd"}, +} + +// archAlias resolves a friendly arch name to GOARCH plus optional GOARM. +type archAlias struct { + goarch string + goarm string +} + +var archAliases = map[string]archAlias{ + "amd64": {goarch: "amd64"}, + "x64": {goarch: "amd64"}, + "x86-64": {goarch: "amd64"}, + "386": {goarch: "386"}, + "x86": {goarch: "386"}, + "i386": {goarch: "386"}, + "32bit": {goarch: "386"}, + "arm64": {goarch: "arm64"}, + "aarch64": {goarch: "arm64"}, + "arm": {goarch: "arm", goarm: "7"}, + "armv7": {goarch: "arm", goarm: "7"}, + "armv6": {goarch: "arm", goarm: "6"}, + "armv5": {goarch: "arm", goarm: "5"}, +} + +// target is one GOOS/GOARCH(/GOARM) build. +type target struct { + goos string + goarch string + goarm string +} + +func resolvePlatforms(raw string) ([]string, error) { + gooses, ok := platformAliases[strings.ToLower(strings.TrimSpace(raw))] + if !ok { + names := make([]string, 0, len(platformAliases)) + for name := range platformAliases { + names = append(names, name) + } + return nil, fmt.Errorf("unknown platform %q (valid: %s)", raw, strings.Join(names, ", ")) + } + return gooses, nil +} + +func resolveArch(raw string) (archAlias, error) { + name := strings.ToLower(strings.TrimSpace(raw)) + if a, ok := archAliases[name]; ok { + return a, nil + } + return archAlias{}, fmt.Errorf("unknown arch %q (valid examples: amd64, x64, arm64, aarch64, 386, x86, arm, armv7)", raw) +} + +// supported pairs "goos/goarch" from the local toolchain. +func supportedPairs() (map[string]bool, error) { + out, err := exec.Command("go", "tool", "dist", "list").Output() + if err != nil { + return nil, fmt.Errorf("cannot list toolchain targets: %w", err) + } + pairs := make(map[string]bool) + for _, line := range strings.Split(string(out), "\n") { + if line = strings.TrimSpace(line); line != "" { + pairs[line] = true + } + } + return pairs, nil +} + +func outputName(t target) string { + name := fmt.Sprintf("isthislegit-%s-%s", t.goos, t.goarch) + if t.goarm != "" { + name += "v" + t.goarm + } + if t.goos == "windows" { + name += ".exe" + } + return name +} + +// isHost reports whether t matches the machine running compile.go. +func (t target) isHost() bool { + if t.goos != runtime.GOOS || t.goarch != runtime.GOARCH { + return false + } + if t.goarch == "arm" { + return t.goarm == goarmHost() + } + return true +} + +// goarmHost reads the host GOARM (empty unless arm, where it defaults to 7). +func goarmHost() string { + if arm := os.Getenv("GOARM"); arm != "" { + return arm + } + return "7" +} + +func build(t target, cgo bool) error { + // Both modes strip symbol tables and DWARF via the compiler; the + // linkage (static vs dynamic) comes from CGO_ENABLED alone. + args := []string{"build", `-ldflags=-s -w`} + cgoEnabled := "0" + if cgo { + cgoEnabled = "1" + } + args = append(args, "-o", outputName(t), "./cmd/isthislegit") + + cmd := exec.Command("go", args...) + cmd.Env = append(os.Environ(), + "GOOS="+t.goos, + "GOARCH="+t.goarch, + "CGO_ENABLED="+cgoEnabled, + ) + if t.goarm != "" { + cmd.Env = append(cmd.Env, "GOARM="+t.goarm) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + fmt.Printf("[*] GOOS=%s GOARCH=%s CGO_ENABLED=%s", t.goos, t.goarch, cgoEnabled) + if t.goarm != "" { + fmt.Printf(" GOARM=%s", t.goarm) + } + fmt.Printf(" -> %s\n", outputName(t)) + if err := cmd.Run(); err != nil { + return fmt.Errorf("build %s/%s failed: %w", t.goos, t.goarch, err) + } + return nil +} + +func run() int { + platformFlag := flag.String("platform", "linux", "target OS: linux, mac/darwin, windows/win, bsd (freebsd+openbsd+netbsd), or a single BSD") + archFlag := flag.String("arch", "amd64", "target arch: amd64/x64, arm64/aarch64, 386/x86, arm/armv7, ...") + staticFlag := flag.Bool("static", true, "true for static (CGO_ENABLED=0), false for dynamic (CGO_ENABLED=1, host builds); both stripped via -ldflags") + flag.Parse() + + gooses, err := resolvePlatforms(*platformFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "[!] Error: %v.\n", err) + return 1 + } + arch, err := resolveArch(*archFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "[!] Error: %v.\n", err) + return 1 + } + pairs, err := supportedPairs() + if err != nil { + fmt.Fprintf(os.Stderr, "[!] Error: %v.\n", err) + return 1 + } + + var targets []target + for _, goos := range gooses { + if !pairs[goos+"/"+arch.goarch] { + fmt.Fprintf(os.Stderr, "[!] Error: target %s/%s is not supported by this toolchain.\n", goos, arch.goarch) + return 1 + } + targets = append(targets, target{goos: goos, goarch: arch.goarch, goarm: arch.goarm}) + } + + // Dynamic builds need a C compiler for the target: gcc covers host + // builds, while cross builds require CC set to a cross compiler. + if !*staticFlag && os.Getenv("CC") == "" { + for _, t := range targets { + if !t.isHost() { + fmt.Fprintf(os.Stderr, "[!] Error: --static=false needs CGO, which cannot cross-compile to %s/%s without a cross C compiler (set CC or use --static=true).\n", t.goos, t.goarch) + return 1 + } + } + } + + built := 0 + for _, t := range targets { + if err := build(t, !*staticFlag); err != nil { + fmt.Fprintf(os.Stderr, "[!] Error: %v.\n", err) + return 1 + } + built++ + } + fmt.Printf("[+] Built %d of %d target(s).\n", built, len(targets)) + return 0 +} + +func main() { + os.Exit(run()) +} diff --git a/docs/dns.html b/docs/dns.html index 521baff..f2041a5 100644 --- a/docs/dns.html +++ b/docs/dns.html @@ -30,14 +30,15 @@ dns.ValidSyntax("no-dot") // => false

CheckDNS

-
func CheckDNS(ctx context.Context, domain string) error
+
func CheckDNS(ctx context.Context, domain string, timeout time.Duration) error

Aborts early on NXDOMAIN or unresolvable names via -net.DefaultResolver.LookupIP with a 5 second deadline. Returns +net.DefaultResolver.LookupIP with the given deadline. Returns nil when at least one IPv4 address resolves, an error otherwise.

+
ParameterDescription
ctxParent context; the global 15 second budget.
domainAlready-normalized domain to resolve.
timeoutPer-call deadline (the CLI --timeout value).
diff --git a/docs/main.html b/docs/main.html index 40b4271..383f3f9 100644 --- a/docs/main.html +++ b/docs/main.html @@ -12,17 +12,20 @@ symbols; this page documents behavior.

Arguments

Syntax: isthislegit [FLAGS] <legit_domain> <suspect_domain>. Flags -v/--verbose, --j/--json, --scale, -h/--help, plus a +-j/--json, -k/--insecure (skips RDAP TLS verification with +a warning), --timeout=DUR (Go duration, default 5s), +--scale, -h/--help, plus a -- separator. Unknown flags and anything other than exactly two positional domains exit 1 with a usage message. Domains are normalized with dns.NormalizeDomain and validated -with dns.ValidSyntax.

+with dns.ValidSyntax. Fail-closed errors +include the underlying fetch cause(s).

Budgets

- +
BudgetValue
Global deadline (both domains)15 seconds
Per-call deadline (DNS, TLS, RDAP)5 seconds
Per-call deadline (DNS, TLS, RDAP)5 seconds, adjustable with --timeout
Attempts per call3, with 1 second between retries
diff --git a/docs/overview.html b/docs/overview.html index 1b65618..859caea 100644 --- a/docs/overview.html +++ b/docs/overview.html @@ -23,6 +23,8 @@ go test ./... FlagDescription -v, --verboseAppend raw cert policy OIDs, TLS details, and RDAP JSON. -j, --jsonOutput the result summary as JSON for scripting. +-k, --insecureSkip TLS verification for RDAP (weakens results; last resort for devices without CA certificates). +--timeout=DURPer-call deadline for DNS/TLS/RDAP; Go duration, default 5s. --scalePrint the scoring scale table and exit. -h, --helpDisplay usage and exit. @@ -30,8 +32,10 @@ go test ./...

Fetch Pipeline

Both domains are fetched concurrently with golang.org/x/sync/errgroup under a 15 second global deadline; each -DNS, TLS, and RDAP call carries a 5 second deadline and is retried up to 3 -times. Total run time equals the slower of the two domains.

+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
@@ -40,6 +44,14 @@ times. Total run time equals the slower of the two domains.

--help), 1 on bad flags, invalid domains, DNS failure, or failed data-quality verification.

+

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 +certificate inspection still work there, while verified HTTPS (RDAP) fails; +the fail-closed error names the cause (x509: certificate signed by +unknown authority). Install ca-certificates and fix the clock +where possible, otherwise use -k/--insecure as a last resort.

+

Scoring Scale

diff --git a/internal/dns/dns.go b/internal/dns/dns.go index 2e3920a..e5f6634 100644 --- a/internal/dns/dns.go +++ b/internal/dns/dns.go @@ -38,8 +38,8 @@ func ValidSyntax(d string) bool { // CheckDNS aborts early on NXDOMAIN / unresolvable names. // Mirrors bash check_dns (dig +short A). Uses the stdlib resolver. -func CheckDNS(ctx context.Context, domain string) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) +func CheckDNS(ctx context.Context, domain string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() addrs, err := net.DefaultResolver.LookupIP(ctx, "ip4", domain) if err != nil {
SignalPoints