diff --git a/CHANGELOG.md b/CHANGELOG.md index d529b3f..f5348c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,8 @@ Full diffs for every release are available via GitHub's unit, Docker container (docker-proxy included) or Kubernetes pod; `explain` joins it with an outside scan and classifies each port as EXPOSED (with the owner and a concrete fix), FORWARDED (reachable with no local listener), - SHIELDED (only the firewall protects it), LOCAL or UNTESTED. Exits 1 when a + SHIELDED (only the firewall protects it), REFUSED (e.g. a stale Docker port + mapping), LOCAL or UNTESTED. Exits 1 when a port not declared with `--expect` is reachable. ### Changed diff --git a/README.md b/README.md index 57b5d97..0d23810 100644 --- a/README.md +++ b/README.md @@ -317,15 +317,14 @@ A scanner sees *that* port 5000 is open, not *which program* is behind it or *wh # 1. On the host (Linux, as root): every listening TCP socket and what owns it -- # process, systemd unit, Docker container (incl. docker-proxy), Kubernetes pod, # plus NodePort/LoadBalancer Services. Reads /proc; sends nothing on the network. -sudo tcpcat inventory -o inventory.json +sudo tcpcat inventory # writes inventory.json -# 2. From ANOTHER machine: an ordinary scan of that host. -tcpcat 203.0.113.5 -sT -Pn -p 1-65535 -j scan.json - -# 3. Anywhere: explain every port. -tcpcat explain inventory.json scan.json --expect 80,443 +# 2. Copy inventory.json to ANOTHER machine (your laptop) and run there: +tcpcat explain inventory.json --expect 22,80,443,25565-25570 ``` +`explain` takes the host's public address from the inventory, probes from where it runs only the ports that matter (the listening ones, plus Kubernetes Service and node ports), slowly and with retries so anti-scan protections on the path don't make results flicker, then explains each port. It refuses to run on the host itself. Before trusting any result it probes a few ports where, per the inventory, nothing listens: if they answer, a device on the path (VPN, phone hotspot, proxy, antivirus web shield, anti-DDoS mitigation) is answering for the host, and explain stops instead of reporting every port as open. Use `--target` when the host has several public addresses or sits behind NAT, `--ports` to probe more ports, and `tcpcat explain inventory.json scan.json` to explain a scan you ran yourself. + ``` [!!] EXPOSED 5000/tcp reachable from the scanner, and docker-proxy (pid 811, container mock-aws) listens on 0.0.0.0 @@ -346,10 +345,11 @@ tcpcat explain inventory.json scan.json --expect 80,443 | `EXPOSED` | reachable, and a local process listens on it: owner and a concrete fix (Redis, PostgreSQL, MySQL/MariaDB, MongoDB, SSH, Docker, Node/Python/Java apps...) | | `FORWARDED` | reachable with no local listener for that address: Docker/Kubernetes port mapping, DNAT, cloud load balancer (the matching Kubernetes Service is named when kubectl is available) | | `SHIELDED` | listens on every interface, but the firewall blocks it: one rule away from exposure | +| `REFUSED` | listens, but connections are refused: a REJECT rule, or a Docker-published port with nothing listening inside the container | | `LOCAL` | bound to loopback or another address only | | `UNTESTED` | listens on a reachable address the scan didn't probe | -`explain` exits `1` when something reachable wasn't declared with `--expect`, so it can run in CI. Scan from another machine: a host scanning its own address goes through the loopback path and bypasses most firewall rules. Behind 1:1 NAT (most clouds), pass the scanned public address with `--target`. Secret-looking command-line arguments are redacted from the inventory. +`explain` exits `1` when something reachable wasn't declared with `--expect`, so it can run in CI. Secret-looking command-line arguments are redacted from the inventory; still, it describes the inside of the host, so don't publish it. ### Segmentation testing as code @@ -609,7 +609,7 @@ tcpcat evidence keygen --out tcpcat evidence verify [--pub ] tcpcat replay [--pub ] [--finding ] [-j f] [--timeout ms] tcpcat inventory [-o inventory.json] -tcpcat explain [--target ip] [--expect 80,443] [-j f] +tcpcat explain [scan.json] [--expect 22,80,443,8000-8010] [--target ip] [--ports p] [-j f] ``` Exit status: `0` compliant / all fixed, `1` violation / still reproducing, `2` usage or configuration error. diff --git a/cmd/tcpcat/explain.go b/cmd/tcpcat/explain.go index bfc6d8f..5d57b18 100644 --- a/cmd/tcpcat/explain.go +++ b/cmd/tcpcat/explain.go @@ -4,31 +4,38 @@ import ( "encoding/json" "flag" "fmt" + "net" "os" "sort" - "strconv" "strings" "github.com/NycolazSec/tcpcat/config" "github.com/NycolazSec/tcpcat/internal/compare" "github.com/NycolazSec/tcpcat/internal/explain" "github.com/NycolazSec/tcpcat/internal/inventory" + "github.com/NycolazSec/tcpcat/internal/scan" ) -const explainUsage = `Usage: - tcpcat inventory [-o inventory.json] - On the host (Linux, as root): list every listening TCP port with the - process, systemd unit, container or pod that owns it. Sends nothing - on the network. - - tcpcat explain [--target ] [--expect 80,443] [-j report.json] - Join that inventory with a scan of the same host taken from OUTSIDE - (tcpcat -p 1-65535 -j scan.json, from another machine) and - explain every port: EXPOSED (who listens, how to fix), FORWARDED - (reachable with no local listener: NAT/Docker/Kubernetes), SHIELDED - (only the firewall protects it), LOCAL, UNTESTED. - --expect lists ports meant to be public. Exit status: 0 nothing - unexpected is reachable, 1 something is, 2 error. +const explainUsage = `Explain who is behind each reachable port of a Linux host, and how to fix it. + + 1. On the host, as root: tcpcat inventory + (writes inventory.json; sends nothing on the network) + 2. Copy inventory.json to ANOTHER machine (your laptop), then run there: + tcpcat explain inventory.json --expect 22,80,443 + + explain finds the host's public address in the inventory, probes the ports + that matter from where it runs, and sorts every port into EXPOSED (who + listens, how to fix), FORWARDED (NAT/Docker/Kubernetes), SHIELDED (only the + firewall protects it), REFUSED, LOCAL. + + --expect ports meant to be public, e.g. 22,80,443,25565-25570 + --target address to probe (needed when the host has several public + addresses, or sits behind NAT) + --ports extra ports to probe + -j also write the result as JSON + + Exit status: 0 nothing unexpected is reachable, 1 something is, 2 error. + Advanced: tcpcat explain inventory.json scan.json uses a scan you ran yourself. ` func runInventory(args []string) int { @@ -53,46 +60,21 @@ func runInventory(args []string) int { } return inv.Listeners[i].Address < inv.Listeners[j].Address }) - fmt.Printf("%s%-6s %-28s %s%s\n", config.Bold, "PORT", "BOUND TO", "OWNER", config.Reset) - for _, l := range inv.Listeners { - fmt.Printf("%-6d %-28s %s\n", l.Port, l.Address, ownerSummary(l.Owner)) - } if err := inv.Write(*out); err != nil { fmt.Printf("%s[!] %v%s\n", config.Red, err, config.Reset) return exitError } - fmt.Printf("%s[✓] %d listening socket(s) on %s written to %s.%s\n", config.White, len(inv.Listeners), inv.Hostname, *out, config.Reset) - fmt.Printf("%s[*] Next: scan this host from another machine (tcpcat
-sT -Pn -p 1-65535 -j scan.json), then: tcpcat explain %s scan.json%s\n", - config.Gray, *out, config.Reset) + renderInventory(inv, *out) return exitOK } -func ownerSummary(o *inventory.Owner) string { - if o == nil { - return "?" - } - s := fmt.Sprintf("%s[%d]", o.Process, o.PID) - switch { - case o.ContainerName != "": - s += " container=" + o.ContainerName - case o.Pod != "": - s += " pod=" + o.Pod - case o.Container != "": - s += " container=" + o.Container[:12] - case o.Unit != "": - s += " unit=" + o.Unit - } - if o.ForwardsTo != "" { - s += " -> " + o.ForwardsTo - } - return s -} - func runExplain(args []string) int { fs := flag.NewFlagSet("explain", flag.ContinueOnError) targetIP := fs.String("target", "", "Scanned address that is this host (needed behind NAT)") expectList := fs.String("expect", "", "Comma-separated ports meant to be public (e.g. 80,443)") jsonOut := fs.String("j", "", "Write the explanation as JSON") + extraPorts := fs.String("ports", "", "Extra ports to probe besides the ones the inventory lists (e.g. 3306,8000-8010)") + verbose := fs.Bool("v", false, "Also detail expected and local-only ports") fs.Usage = func() { fmt.Print(explainUsage) } var positional, flags []string @@ -100,7 +82,7 @@ func runExplain(args []string) int { a := args[i] if strings.HasPrefix(a, "-") { flags = append(flags, a) - if !strings.Contains(a, "=") && i+1 < len(args) { + if !strings.Contains(a, "=") && a != "-v" && i+1 < len(args) { flags = append(flags, args[i+1]) i++ } @@ -111,21 +93,30 @@ func runExplain(args []string) int { if err := fs.Parse(flags); err != nil { return exitError } - if len(positional) != 2 { + if len(positional) < 1 || len(positional) > 2 { fmt.Print(explainUsage) return exitError } expected := map[int]bool{} if *expectList != "" { - for _, p := range strings.Split(*expectList, ",") { - port, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(p), "/tcp"))) - if err != nil || port < 1 || port > 65535 { - fmt.Printf("%s[!] --expect: invalid port %q%s\n", config.Red, p, config.Reset) - return exitError - } - expected[port] = true + ports, err := explain.ParsePortList(*expectList) + if err != nil { + fmt.Printf("%s[!] --expect: %v%s\n", config.Red, err, config.Reset) + return exitError + } + for _, p := range ports { + expected[p] = true + } + } + var extra []int + if *extraPorts != "" { + ports, err := explain.ParsePortList(*extraPorts) + if err != nil { + fmt.Printf("%s[!] --ports: %v%s\n", config.Red, err, config.Reset) + return exitError } + extra = ports } inv, err := inventory.Load(positional[0]) @@ -133,49 +124,30 @@ func runExplain(args []string) int { fmt.Printf("%s[!] %v%s\n", config.Red, err, config.Reset) return exitError } - results, err := compare.LoadBaseline(positional[1]) - if err != nil { - fmt.Printf("%s[!] scan report %s: %v%s\n", config.Red, positional[1], err, config.Reset) - return exitError - } - target, err := explain.SelectTarget(inv, results, *targetIP) - if err != nil { - fmt.Printf("%s[!] %v%s\n", config.Red, err, config.Reset) - return exitError - } - rep := explain.Explain(inv, results, target, expected) - fmt.Printf("%s[*] Explaining %s (scanned as %s): %d port(s) seen from inside and/or outside.%s\n", - config.Bold, inv.Hostname, target, len(rep.Entries), config.Reset) - fmt.Println(config.Bold + "────────────────────────────────────────────────────────────────────────────────" + config.Reset) - for _, e := range rep.Entries { - color, mark := config.Gray, "[i]" - switch { - case (e.Class == explain.Exposed || e.Class == explain.Forwarded) && !e.Expected: - color, mark = config.Red, "[!]" - if e.Severity == "high" { - mark = "[!!]" - } - case e.Class == explain.Shielded && e.Severity == "low": - color, mark = config.White, "[~]" - case e.Expected: - color, mark = config.White, "[✓]" - } - label := e.Class - if e.Expected { - label += " (expected)" + var results []scan.TargetResult + var target string + var probe probeSummary + if len(positional) == 2 { + probe.fromFile = positional[1] + // Advanced form: a scan report produced separately. + if results, err = compare.LoadBaseline(positional[1]); err != nil { + fmt.Printf("%s[!] scan report %s: %v%s\n", config.Red, positional[1], err, config.Reset) + return exitError } - svc := "" - if e.Service != "" && e.Service != "unknown" { - svc = " " + e.Service + if target, err = explain.SelectTarget(inv, results, *targetIP); err != nil { + fmt.Printf("%s[!] %v%s\n", config.Red, err, config.Reset) + return exitError } - fmt.Printf("%s%-4s %-20s %5d/tcp%s%s\n", color, mark, label, e.Port, svc, config.Reset) - fmt.Printf(" %s%s%s\n", config.Gray, e.Why, config.Reset) - if e.Fix != "" && (!e.Expected || e.Class != explain.Exposed) { - fmt.Printf(" %sfix: %s%s\n", config.White, e.Fix, config.Reset) + } else { + if target, results, probe, err = scanFromHere(inv, *targetIP, extra, expected); err != nil { + fmt.Printf("%s[!] %v%s\n", config.Red, err, config.Reset) + return exitError } } - fmt.Println(config.Bold + "────────────────────────────────────────────────────────────────────────────────" + config.Reset) + + rep := explain.Explain(inv, results, target, expected) + renderExplain(rep, inv, probe, *verbose) if *jsonOut != "" { data, _ := json.MarshalIndent(rep, "", " ") @@ -183,12 +155,126 @@ func runExplain(args []string) int { fmt.Printf("%s[!] %v%s\n", config.Red, err, config.Reset) return exitError } - fmt.Printf("%s[✓] Explanation written to %s%s\n", config.White, *jsonOut, config.Reset) + fmt.Printf(" %sJSON report written to %s%s\n", config.Gray, *jsonOut, config.Reset) } - if n := rep.Problems(); n > 0 { - fmt.Printf("%s[✗] %d port(s) reachable without being declared with --expect.%s\n", config.Red, n, config.Reset) + if rep.Problems() > 0 { return exitViolation } - fmt.Printf("%s[✓] Nothing unexpected is reachable on %s.%s\n", config.White, target, config.Reset) return exitOK } + +// scanFromHere probes the host described by inv from this machine and +// returns the address used, the results and a summary for the header. It +// refuses to run on the host itself: connections to one's own address go +// through the loopback path and skip most firewall rules, so everything +// would look reachable. +func scanFromHere(inv inventory.Inventory, given string, extra []int, expected map[int]bool) (string, []scan.TargetResult, probeSummary, error) { + var sum probeSummary + target := given + if target == "" { + public := explain.PublicAddresses(inv) + if len(public) == 0 { + return "", nil, sum, fmt.Errorf("%s has no public address in its inventory (behind NAT?): pass the address to probe with --target", inv.Hostname) + } + target = public[0] + if len(public) > 1 { + fmt.Printf(" %s%s also has %s; probing %s (--target to pick another).%s\n", + config.Gray, inv.Hostname, strings.Join(public[1:], ", "), target, config.Reset) + } + } + if net.ParseIP(target) == nil { + return "", nil, sum, fmt.Errorf("--target %q is not an IP address", target) + } + if isLocalAddress(target) { + return "", nil, sum, fmt.Errorf("this machine owns %s: run explain from ANOTHER machine (your laptop), so the probes cross the firewall like real visitors", target) + } + + var expectedList []int + for p := range expected { + expectedList = append(expectedList, p) + } + ports := explain.PortsToProbe(inv, append(extra, expectedList...)) + + // A few slow, retried connect probes: the goal is a result that doesn't + // flicker between runs, not speed (the port list is short). + opts := auditScanOptions(20, 3) + opts.MaxWorkers = 5 + opts.MaxRetries = 3 + opts.OnlyOpen = false // explain needs closed/filtered results too (SHIELDED, REFUSED) + opts.Quiet = true + engine := scan.NewEngine(opts) + + fmt.Printf(" %s●%s Probing %s%s%s on %d port(s) from this machine… ", config.Red, config.Reset, config.Bold+config.White, target, config.Reset, len(ports)) + results := engine.Execute([]string{target}, ports) + for _, r := range results { + if r.State == scan.StateOpen { + sum.open++ + } + } + sum.probed = len(ports) + fmt.Printf("%s%d open%s\n", config.White, sum.open, config.Reset) + + canaries := explain.CanaryPorts(inv, ports, 3) + fmt.Printf(" %s●%s Checking the path with %d control port(s) where nothing listens… ", config.Red, config.Reset, len(canaries)) + var answered []string + for _, r := range engine.Execute([]string{target}, canaries) { + if r.State == scan.StateOpen { + answered = append(answered, fmt.Sprint(r.Port)) + } + } + if len(answered) > 0 { + fmt.Printf("%sFAILED%s\n", config.Bold+config.Red, config.Reset) + cause := "a device between this machine and the host (VPN, phone hotspot, proxy, antivirus web shield, or anti-DDoS mitigation) answers on the host's behalf" + if iface, addr := tunnelInterfaceFor(target); iface != "" { + cause = fmt.Sprintf("traffic to %s leaves through tunnel interface %s (%s): a VPN is connected and its server answers on every port", target, iface, addr) + } + return "", nil, sum, fmt.Errorf("control port(s) %s answered although nothing listens there on %s: %s, so no result can be trusted. Disconnect the VPN (or exclude %s from it) and run explain again", + strings.Join(answered, ", "), inv.Hostname, cause, target) + } + fmt.Printf("%sok%s\n", config.White, config.Reset) + sum.pathCheck = true + return target, results, sum, nil +} + +func isLocalAddress(ip string) bool { + addrs, err := net.InterfaceAddrs() + if err != nil { + return false + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.String() == ip { + return true + } + } + return false +} + +// tunnelInterfaceFor reports the tunnel interface (utun/tun/wg/ppp/ipsec) +// the kernel would route target through, if any. Connecting a UDP socket +// only selects a route; nothing is sent. +func tunnelInterfaceFor(target string) (string, string) { + conn, err := net.Dial("udp", net.JoinHostPort(target, "9")) + if err != nil { + return "", "" + } + local := conn.LocalAddr().(*net.UDPAddr).IP + _ = conn.Close() + ifaces, err := net.Interfaces() + if err != nil { + return "", "" + } + for _, ifc := range ifaces { + addrs, _ := ifc.Addrs() + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.Equal(local) { + for _, prefix := range []string{"utun", "tun", "wg", "ppp", "ipsec", "tap", "proton", "nordlynx"} { + if strings.HasPrefix(ifc.Name, prefix) { + return ifc.Name, local.String() + } + } + return "", "" + } + } + } + return "", "" +} diff --git a/cmd/tcpcat/explain_view.go b/cmd/tcpcat/explain_view.go new file mode 100644 index 0000000..8bb4779 --- /dev/null +++ b/cmd/tcpcat/explain_view.go @@ -0,0 +1,317 @@ +package main + +import ( + "fmt" + "net" + "sort" + "strconv" + "strings" + + "github.com/NycolazSec/tcpcat/config" + "github.com/NycolazSec/tcpcat/internal/explain" + "github.com/NycolazSec/tcpcat/internal/inventory" +) + +// Console rendering of `tcpcat explain`: a header, one section per verdict +// (problems first), local-only ports folded into one line, and a verdict. +// Palette per config: red = needs attention, white = primary, gray = detail. + +const ruleWidth = 80 + +func rule() string { return config.Gray + strings.Repeat("─", ruleWidth) + config.Reset } + +// probeSummary describes how the outside view was obtained, for the header. +type probeSummary struct { + probed int // ports probed from this machine (0: scan report given) + open int // of which answered open + pathCheck bool // canary ports confirmed nothing answers on the host's behalf + fromFile string +} + +type section struct { + title, hint, color string + entries []explain.Entry + // footer replaces a per-entry generic fix shared by the whole section. + footer string +} + +func renderExplain(rep explain.Report, inv inventory.Inventory, probe probeSummary, verbose bool) { + fmt.Println(rule()) + kv := func(k, v string) { fmt.Printf(" %s%-8s%s %s\n", config.Gray, k, config.Reset, v) } + kv("HOST", config.Bold+config.White+inv.Hostname+config.Reset+config.Gray+" · "+rep.Target+config.Reset) + kv("INSIDE", fmt.Sprintf("%d listening socket(s), inventory taken %s", len(inv.Listeners), inv.CollectedAt.Format("2006-01-02 15:04 UTC"))) + if probe.fromFile != "" { + kv("OUTSIDE", "scan report "+probe.fromFile) + } else { + check := config.Red + "not run" + config.Reset + if probe.pathCheck { + check = "passed" + } + kv("OUTSIDE", fmt.Sprintf("%d port(s) probed from this machine, %d open · path check %s", probe.probed, probe.open, check)) + } + fmt.Println(rule()) + + var fix, firewall, refused, untested, expected, local []explain.Entry + for _, e := range rep.Entries { + switch { + case e.Expected && e.Class != explain.Local && e.Class != explain.Refused: + expected = append(expected, e) + case e.Class == explain.Exposed || e.Class == explain.Forwarded: + fix = append(fix, e) + case e.Class == explain.Shielded: + firewall = append(firewall, e) + case e.Class == explain.Refused: + refused = append(refused, e) + case e.Class == explain.Untested: + untested = append(untested, e) + default: + local = append(local, e) + } + } + + sections := []section{ + {"TO FIX", "reachable from outside, not declared with --expect", config.Red, fix, ""}, + {"FIREWALL ONLY", "listening on every interface; one firewall rule keeps them off the network", config.White, firewall, + "Defense in depth: bind the ones nothing remote needs to 127.0.0.1, so a firewall mistake can't expose them."}, + {"REFUSED", "listening, but connections are turned away", config.White, refused, ""}, + {"NOT PROBED", "listening on a reachable address; add them with --ports", config.White, untested, + "Re-run with --ports to probe them."}, + } + for _, s := range sections { + if len(s.entries) == 0 { + continue + } + fmt.Printf("\n %s%s%s (%d)%s %s%s%s\n", config.Bold, s.color, s.title, len(s.entries), config.Reset, config.Gray, s.hint, config.Reset) + for _, e := range s.entries { + // With a section footer, only a daemon-specific fix (it names + // the knob in parentheses) is worth repeating per port. + renderEntry(e, s.color, s.footer == "" || strings.Contains(e.Fix, "(")) + } + if s.footer != "" { + fmt.Printf(" %-21s %s→ %s%s\n", "", config.Gray, s.footer, config.Reset) + } + } + + if len(expected) > 0 { + fmt.Printf("\n %s%sPUBLIC AS EXPECTED (%d)%s\n", config.Bold, config.White, len(expected), config.Reset) + for _, e := range expected { + renderEntry(e, config.White, verbose) + } + } + + if len(local) > 0 { + fmt.Printf("\n %s%sLOCAL ONLY (%d)%s ", config.Bold, config.White, len(local), config.Reset) + if verbose { + fmt.Printf("%sloopback or another address; not reachable%s\n", config.Gray, config.Reset) + for _, e := range local { + renderEntry(e, config.Gray, false) + } + } else { + ports := make([]int, 0, len(local)) + for _, e := range local { + ports = append(ports, e.Port) + } + fmt.Printf("%s%s%s %s(-v for details)%s\n", config.White, compactPorts(ports), config.Reset, config.Gray, config.Reset) + } + } + + fmt.Println() + fmt.Println(rule()) + n := rep.Problems() + if n > 0 { + fmt.Printf(" %s%s✗ %d port(s) reachable that should not be.%s %sFix them, or declare the intended ones with --expect.%s\n", + config.Bold, config.Red, n, config.Reset, config.Gray, config.Reset) + } else { + fmt.Printf(" %s%s✓ Nothing unexpected is reachable on %s.%s\n", config.Bold, config.White, rep.Target, config.Reset) + } + if len(firewall) > 0 { + fmt.Printf(" %s%d port(s) rely on the firewall alone (defense in depth: also bind them to 127.0.0.1).%s\n", config.Gray, len(firewall), config.Reset) + } + fmt.Println(rule()) +} + +// renderEntry prints one port: a headline (port, verdict, owner, severity), +// a short reason, and -- when detailed -- the fix, wrapped. +func renderEntry(e explain.Entry, color string, detailed bool) { + sev := "" + switch e.Severity { + case "high": + sev = config.Bold + config.Red + "HIGH" + config.Reset + case "medium": + sev = config.Red + "MEDIUM" + config.Reset + case "low": + sev = config.Gray + "LOW" + config.Reset + } + if e.Expected { + sev = "" + } + owner := shortOwner(e) + fmt.Printf(" %s%-10s%s %s%-10s%s %-44s %s\n", + config.Bold+color, fmt.Sprintf("%d/tcp", e.Port), config.Reset, + config.Gray, e.Class, config.Reset, + truncate(owner, 44), sev) + fmt.Printf(" %s%-21s %s%s\n", config.Gray, "", shortWhy(e), config.Reset) + if detailed && e.Fix != "" && (!e.Expected || e.Class == explain.Refused) { + for i, line := range wrap(e.Fix, ruleWidth-28) { + arrow := " " + if i == 0 { + arrow = "→" + } + fmt.Printf(" %-21s %s%s %s%s\n", "", config.White, arrow, line, config.Reset) + } + } +} + +// shortOwner: "mariadbd · mariadb.service", "docker-proxy · container mock-aws", +// "→ kube-system/traefik (LoadBalancer)". +func shortOwner(e explain.Entry) string { + if e.Kube != nil { + return "→ " + e.Kube.Name + " (" + e.Kube.Type + ")" + } + o := e.Owner + if o == nil { + if e.Class == explain.Forwarded { + return "→ forwarded (no local listener)" + } + return "unidentified process" + } + s := o.Process + switch { + case o.ContainerName != "": + s += " · container " + shortID(o.ContainerName) + case o.Pod != "": + s += " · pod " + o.Pod + case o.Container != "": + s += " · container " + shortID(o.Container) + case o.Unit != "": + s += " · " + o.Unit + } + return s +} + +// shortWhy is the one-line reason shown under each port; the full sentence +// stays in the JSON report. +func shortWhy(e explain.Entry) string { + bound := strings.Join(e.Listeners, ", ") + switch e.Class { + case explain.Exposed: + return "reachable from outside · listens on " + bound + case explain.Forwarded: + if e.Kube != nil { + why := "reachable · Kubernetes redirects it into the cluster" + if e.Owner != nil && len(e.Listeners) > 0 { + why += " (" + e.Owner.Process + " also listens locally, gets none of it)" + } + return why + } + return "reachable, but nothing on the host listens for it: NAT / port forwarding" + case explain.Shielded: + return "listens on " + bound + " · blocked from outside" + case explain.Refused: + if e.Owner != nil && e.Owner.Process == "docker-proxy" { + return "Docker publishes it, but nothing listens on " + e.Owner.ForwardsTo + " in the container" + } + return "listens on " + bound + " · connections refused" + case explain.Untested: + return "listens on " + bound + " · not probed" + } + return "listens on " + bound + " only" +} + +// shortID shortens 64-hex container IDs and Pterodactyl-style UUID names. +func shortID(s string) string { + if len(s) >= 32 && strings.Trim(strings.ToLower(s), "0123456789abcdef-") == "" { + return s[:8] + "…" + } + return s +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n-1]) + "…" +} + +func wrap(text string, width int) []string { + var lines []string + line := "" + for _, w := range strings.Fields(text) { + if line != "" && len([]rune(line))+1+len([]rune(w)) > width { + lines = append(lines, line) + line = w + continue + } + if line != "" { + line += " " + } + line += w + } + if line != "" { + lines = append(lines, line) + } + return lines +} + +// compactPorts: [25 53 10256 10257 10258] -> "25, 53, 10256-10258". +func compactPorts(ports []int) string { + sort.Ints(ports) + var parts []string + for i := 0; i < len(ports); { + j := i + for j+1 < len(ports) && ports[j+1] == ports[j]+1 { + j++ + } + if j > i { + parts = append(parts, fmt.Sprintf("%d-%d", ports[i], ports[j])) + } else { + parts = append(parts, strconv.Itoa(ports[i])) + } + i = j + 1 + } + return strings.Join(parts, ", ") +} + +// renderInventory prints the inside view grouped by exposure surface. +func renderInventory(inv inventory.Inventory, out string) { + var wide, local []inventory.Listener + for _, l := range inv.Listeners { + if ip := net.ParseIP(l.Address); ip != nil && ip.IsLoopback() { + local = append(local, l) + } else { + wide = append(wide, l) + } + } + fmt.Println(rule()) + fmt.Printf(" %sHOST%s %s%s%s · %d listening socket(s)\n", config.Gray, config.Reset, config.Bold+config.White, inv.Hostname, config.Reset, len(inv.Listeners)) + fmt.Println(rule()) + print := func(title, hint string, list []inventory.Listener) { + if len(list) == 0 { + return + } + fmt.Printf("\n %s%s%s (%d)%s %s%s%s\n", config.Bold, config.White, title, len(list), config.Reset, config.Gray, hint, config.Reset) + for _, l := range list { + fmt.Printf(" %s%-10s%s %-24s %s\n", config.Bold+config.White, fmt.Sprintf("%d/tcp", l.Port), config.Reset, + truncate(l.Address, 24), truncate(shortOwner(explain.Entry{Owner: l.Owner}), 44)) + } + } + print("NETWORK-FACING", "bound to every interface or a non-loopback address", wide) + print("LOOPBACK ONLY", "not reachable from the network", local) + if len(inv.Services) > 0 { + fmt.Printf("\n %s%sKUBERNETES SERVICES (%d)%s %sNodePort / LoadBalancer%s\n", config.Bold, config.White, len(inv.Services), config.Reset, config.Gray, config.Reset) + for _, s := range inv.Services { + np := "" + if s.NodePort > 0 { + np = fmt.Sprintf(" (node port %d)", s.NodePort) + } + fmt.Printf(" %s%-10s%s %s%s\n", config.Bold+config.White, fmt.Sprintf("%d/tcp", s.Port), config.Reset, s.Name+" · "+s.Type, np) + } + } + fmt.Println() + fmt.Println(rule()) + fmt.Printf(" %s✓ Written to %s%s\n", config.White, out, config.Reset) + fmt.Printf(" %sNext: copy %s to another machine (your laptop) and run there:%s\n", config.Gray, out, config.Reset) + fmt.Printf(" %stcpcat explain %s --expect 22,80,443%s\n", config.Bold+config.White, out, config.Reset) + fmt.Println(rule()) +} diff --git a/cmd/tcpcat/main.go b/cmd/tcpcat/main.go index 4f88809..f6819e6 100644 --- a/cmd/tcpcat/main.go +++ b/cmd/tcpcat/main.go @@ -42,6 +42,9 @@ func main() { // flag parsing, which would otherwise read "policy" as a target name. if len(os.Args) > 1 { if run, ok := subcommands[os.Args[1]]; ok { + if !isHelpRequest(os.Args[2:]) { + fmt.Println(config.RenderBanner("")) + } os.Exit(run(os.Args[2:])) } } diff --git a/cmd/tcpcat/subcommands.go b/cmd/tcpcat/subcommands.go index 8f54d21..e98b049 100644 --- a/cmd/tcpcat/subcommands.go +++ b/cmd/tcpcat/subcommands.go @@ -9,3 +9,16 @@ var subcommands = map[string]func([]string) int{ "inventory": runInventory, "explain": runExplain, } + +// isHelpRequest: usage text is printed without the banner, so it reads as +// plain help (and pipes cleanly into a pager). +func isHelpRequest(args []string) bool { + if len(args) == 0 { + return false // `tcpcat inventory` alone is a real run + } + switch args[0] { + case "-h", "--help", "help": + return true + } + return false +} diff --git a/config/options.go b/config/options.go index a53ffd6..156075f 100644 --- a/config/options.go +++ b/config/options.go @@ -149,7 +149,10 @@ type Options struct { EvidenceOutput string EvidenceKey string FingerprintOut string - SmartBypass bool + // Quiet suppresses the scan engine's per-port console lines (callers + // that render their own report, like `tcpcat explain`, set it). + Quiet bool + SmartBypass bool AWSRegion string AWSTags string diff --git a/internal/explain/auto.go b/internal/explain/auto.go new file mode 100644 index 0000000..03eb83c --- /dev/null +++ b/internal/explain/auto.go @@ -0,0 +1,144 @@ +package explain + +import ( + "fmt" + "net" + "sort" + "strconv" + "strings" + + "github.com/NycolazSec/tcpcat/internal/inventory" +) + +// Ranges that are never the address the outside world reaches a host on. +var nonPublic = mustCIDRs( + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", // private, CGNAT + "127.0.0.0/8", "169.254.0.0/16", "::1/128", "fe80::/10", "fc00::/7", +) + +func mustCIDRs(list ...string) []*net.IPNet { + var out []*net.IPNet + for _, c := range list { + _, n, err := net.ParseCIDR(c) + if err != nil { + panic(err) + } + out = append(out, n) + } + return out +} + +// IsPublic reports whether ip is a globally routable unicast address. +func IsPublic(ip net.IP) bool { + if ip == nil || !ip.IsGlobalUnicast() { + return false + } + for _, n := range nonPublic { + if n.Contains(ip) { + return false + } + } + return true +} + +// PublicAddresses lists the host's public addresses, IPv4 first. +func PublicAddresses(inv inventory.Inventory) []string { + var v4, v6 []string + for _, a := range inv.Addresses { + ip := net.ParseIP(a) + if !IsPublic(ip) { + continue + } + if ip.To4() != nil { + v4 = append(v4, a) + } else { + v6 = append(v6, a) + } + } + return append(v4, v6...) +} + +// PortsToProbe is every port worth probing from outside for this host: the +// ones a socket listens on beyond loopback, the ports and node ports of +// externally exposed Kubernetes Services, plus extra. Scanning these few +// instead of all 65535 is faster and far less likely to trip anti-scan +// protections on the path, which otherwise make results flicker. +func PortsToProbe(inv inventory.Inventory, extra []int) []int { + set := map[int]bool{} + for _, l := range inv.Listeners { + if ip := net.ParseIP(l.Address); ip != nil && ip.IsLoopback() { + continue + } + set[l.Port] = true + } + for _, s := range inv.Services { + set[s.Port] = true + if s.NodePort > 0 { + set[s.NodePort] = true + } + } + for _, p := range extra { + set[p] = true + } + out := make([]int, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Ints(out) + return out +} + +// ParsePortList reads "22,80,443,25565-25570" (an optional "/tcp" suffix +// is accepted on each entry). +func ParsePortList(spec string) ([]int, error) { + var out []int + for _, raw := range strings.Split(spec, ",") { + entry := strings.TrimSuffix(strings.TrimSpace(raw), "/tcp") + if entry == "" { + continue + } + lo, hi, isRange := strings.Cut(entry, "-") + start, err := strconv.Atoi(lo) + end := start + if err == nil && isRange { + end, err = strconv.Atoi(hi) + } + if err != nil || start < 1 || end > 65535 || end < start { + return nil, fmt.Errorf("invalid port %q", raw) + } + for p := start; p <= end; p++ { + out = append(out, p) + } + } + return out, nil +} + +// CanaryPorts picks n high ports that, according to the inventory, nothing +// listens on and no Kubernetes Service maps, and that are not in avoid. If +// one of them answers as open from outside, something on the path (VPN, +// transparent proxy, antivirus, anti-DDoS SYN proxy) is completing +// handshakes on the host's behalf, and no "open" result can be trusted. +func CanaryPorts(inv inventory.Inventory, avoid []int, n int) []int { + used := map[int]bool{} + for _, l := range inv.Listeners { + used[l.Port] = true + } + for _, s := range inv.Services { + used[s.Port], used[s.NodePort] = true, true + } + for _, p := range avoid { + used[p] = true + } + // Spread over the dynamic range; fixed seeds keep runs comparable. + candidates := []int{47231, 51873, 58419, 49157, 53987, 61243, 45589, 56701} + var out []int + for _, p := range candidates { + if len(out) == n { + break + } + if !used[p] { + out = append(out, p) + } + } + return out +} diff --git a/internal/explain/explain.go b/internal/explain/explain.go index 1d4766f..d23bac3 100644 --- a/internal/explain/explain.go +++ b/internal/explain/explain.go @@ -8,6 +8,8 @@ // Docker/Kubernetes port mapping, a load balancer // SHIELDED a process listens on every interface but the scan could not // reach it: only the firewall stands between it and the network +// REFUSED a process listens, but connections are refused: a REJECT rule, +// or (Docker) a published port with nothing listening behind it // LOCAL bound to loopback (or another address): not reachable, by design // UNTESTED listening on a reachable address, but the scan didn't probe it // @@ -29,6 +31,7 @@ const ( Exposed = "EXPOSED" Forwarded = "FORWARDED" Shielded = "SHIELDED" + Refused = "REFUSED" Local = "LOCAL" Untested = "UNTESTED" ) @@ -186,6 +189,15 @@ func Explain(inv inventory.Inventory, results []scan.TargetResult, target string e.Why += fmt.Sprintf(" (%s listens on %s only)", ownerLabel(e.Owner), strings.Join(e.Listeners, ", ")) } e.Fix = fixForwarded(e.Kube, port) + case len(s.reachable) > 0 && scanned && r.State == scan.StateClosed: + e.Class = Refused + if e.Owner != nil && e.Owner.Process == "docker-proxy" { + e.Why = fmt.Sprintf("%s publishes this port, but connections are refused: nothing listens on %s inside the container", ownerLabel(e.Owner), e.Owner.ForwardsTo) + e.Fix = "Remove the stale port mapping, or fix the service inside the container so it listens on the published port." + } else { + e.Why = fmt.Sprintf("%s listens on %s, but connections are refused (a firewall REJECT rule, or a service that turns them away)", ownerLabel(e.Owner), strings.Join(e.Listeners, ", ")) + e.Fix = fixShielded(e.Owner, port) + } case len(s.reachable) > 0 && scanned: e.Class = Shielded e.Why = fmt.Sprintf("%s listens on %s, but the scan found it %s: only the firewall keeps it off the network", @@ -203,7 +215,7 @@ func Explain(inv inventory.Inventory, results []scan.TargetResult, target string rep.Entries = append(rep.Entries, e) } - order := map[string]int{Exposed: 0, Forwarded: 1, Shielded: 2, Untested: 3, Local: 4} + order := map[string]int{Exposed: 0, Forwarded: 1, Shielded: 2, Refused: 3, Untested: 4, Local: 5} sort.Slice(rep.Entries, func(i, j int) bool { a, b := rep.Entries[i], rep.Entries[j] if a.Expected != b.Expected { @@ -349,6 +361,9 @@ func fixExposed(o *inventory.Owner, port int) string { } func fixForwarded(k *inventory.KubeService, port int) string { + if k != nil && k.NodePort == port && k.Port != port { + return fmt.Sprintf("NodePort %d of Kubernetes Service %s (%s): every node accepts it, a duplicate entry next to the Service's own port %d. Firewall the NodePort range (30000-32767), or set `allocateLoadBalancerNodePorts: false` on a LoadBalancer Service.", port, k.Name, k.Type, k.Port) + } if k != nil { return fmt.Sprintf("Kubernetes Service %s (%s) maps this port. If it must not be public, make it ClusterIP, or set loadBalancerSourceRanges / a NetworkPolicy.", k.Name, k.Type) } diff --git a/internal/explain/explain_test.go b/internal/explain/explain_test.go index 7409e92..a0e89d6 100644 --- a/internal/explain/explain_test.go +++ b/internal/explain/explain_test.go @@ -1,6 +1,7 @@ package explain import ( + "fmt" "strings" "testing" @@ -34,7 +35,7 @@ func r(port int, state string) scan.TargetResult { func sampleScan() []scan.TargetResult { return []scan.TargetResult{ r(6379, scan.StateOpen), r(5000, scan.StateOpen), r(443, scan.StateOpen), r(80, scan.StateOpen), - r(5432, scan.StateFiltered), r(22, scan.StateClosed), r(8083, scan.StateClosed), + r(5432, scan.StateFiltered), r(22, scan.StateFiltered), r(8083, scan.StateClosed), {IP: "198.51.100.1", Port: 22, State: scan.StateOpen}, // another host: ignored } } @@ -152,3 +153,60 @@ func TestKubeServiceTakesPrecedenceOverLocalListener(t *testing.T) { t.Errorf("80 = %+v", e) } } + +// Seen on a real Pterodactyl host: Docker publishes the port, but nothing +// listens behind it, so connections are refused -- not "firewall only". +func TestRefusedDockerPublishedPort(t *testing.T) { + inv := sampleInventory() + inv.Listeners = append(inv.Listeners, inventory.Listener{Address: target, Port: 25567, + Owner: &inventory.Owner{PID: 20, Process: "docker-proxy", ContainerName: "old-app", ForwardsTo: "172.18.0.2:25567"}}) + e := byPort(Explain(inv, []scan.TargetResult{r(25567, scan.StateClosed)}, target, nil))[25567] + if e.Class != Refused || !strings.Contains(e.Why, "nothing listens on 172.18.0.2:25567") { + t.Errorf("25567 = %+v", e) + } + ssh := byPort(Explain(sampleInventory(), []scan.TargetResult{r(22, scan.StateClosed)}, target, nil))[22] + if ssh.Class != Refused || !strings.Contains(ssh.Why, "REJECT") { + t.Errorf("22 closed = %+v", ssh) + } +} + +func TestNodePortFix(t *testing.T) { + inv := sampleInventory() // traefik: LoadBalancer 443, NodePort 30443 + e := byPort(Explain(inv, []scan.TargetResult{r(30443, scan.StateOpen)}, target, nil))[30443] + if e.Class != Forwarded || !strings.Contains(e.Fix, "allocateLoadBalancerNodePorts") { + t.Errorf("30443 = %+v", e) + } +} + +func TestAutoHelpers(t *testing.T) { + inv := sampleInventory() + inv.Addresses = []string{"127.0.0.1", "10.42.0.1", "172.17.0.1", "203.0.113.5", "::1", "fe80::1", "2001:db8::5", "fd00::1"} + if got := strings.Join(PublicAddresses(inv), ","); got != "203.0.113.5,2001:db8::5" { + t.Errorf("PublicAddresses = %s", got) + } + + ports := PortsToProbe(inv, []int{3306}) + want := "22,80,443,3306,5000,5432,6379,9100,30443" + if got := strings.Trim(strings.Join(strings.Fields(fmt.Sprint(ports)), ","), "[]"); got != want { + t.Errorf("PortsToProbe = %s, want %s (loopback-only 8083 excluded)", got, want) + } + + list, err := ParsePortList("22, 80/tcp,25565-25567") + if err != nil || fmt.Sprint(list) != "[22 80 25565 25566 25567]" { + t.Errorf("ParsePortList = %v, %v", list, err) + } + for _, bad := range []string{"0", "70000", "90-80", "x", "1-y"} { + if _, err := ParsePortList(bad); err == nil { + t.Errorf("ParsePortList(%q) should fail", bad) + } + } +} + +func TestCanaryPortsAvoidKnownPorts(t *testing.T) { + inv := sampleInventory() + inv.Listeners = append(inv.Listeners, inventory.Listener{Address: "0.0.0.0", Port: 47231}) + got := CanaryPorts(inv, []int{51873}, 3) + if fmt.Sprint(got) != "[58419 49157 53987]" { + t.Errorf("CanaryPorts = %v", got) + } +} diff --git a/internal/scan/engine.go b/internal/scan/engine.go index c9ed695..1e91a7c 100644 --- a/internal/scan/engine.go +++ b/internal/scan/engine.go @@ -288,7 +288,7 @@ func (e *Engine) ExecuteWithProgress(targets []string, ports []int, onProgress P fmt.Printf("%s[!] Warning: could not flush resume checkpoint: %v%s\n", config.Red, err, config.Reset) } - if adaptive && limiter != nil { + if adaptive && limiter != nil && !e.opts.Quiet { fmt.Printf("%s[*] Adaptive timing: final rate %d pps, SRTT %v%s\n", config.White, limiter.CurrentRate(), e.rtt.SRTT(), config.Reset) } @@ -309,7 +309,7 @@ func (e *Engine) ExecuteWithProgress(targets []string, ports []int, onProgress P // any) and resets it -- called on every host change and once more // after the loop, so the last host's count isn't dropped. flushClosedSummary := func() { - if hiddenClosed > 0 { + if hiddenClosed > 0 && !e.opts.Quiet { fmt.Printf("%s[~] %s ─ Not shown: %d closed port(s)%s\n", config.Bold+config.White, currentHost, hiddenClosed, config.Reset) hiddenClosed = 0 @@ -326,6 +326,9 @@ func (e *Engine) ExecuteWithProgress(targets []string, ports []int, onProgress P continue } finalResults = append(finalResults, res) + if e.opts.Quiet { + continue + } // CLOSED ports are hidden by default (--show-closed to opt back // in) and rolled into one per-host summary line instead -- a