package main import ( "errors" "fmt" "io" "strings" "github.com/spf13/cobra" ) type exposureFinding struct { Severity string `json:"severity"` Code string `json:"code"` Message string `json:"message"` View string `json:"view"` Fix string `json:"fix"` } type exposureReport struct { Scopes []struct { ViewID int64 `json:"view_id"` View string `json:"view"` Public bool `json:"public"` Override string `json:"override"` Reason string `json:"reason"` RateLimit string `json:"rate_limit"` Findings []exposureFinding `json:"findings"` } `json:"scopes"` Server []exposureFinding `json:"server"` RateLimiting *struct { Since string `json:"since"` Counts []struct { Key string Count int64 } `json:"counts"` Networks []struct { Key string Count int64 } `json:"networks"` Names []struct { Key string Count int64 } `json:"names"` } `json:"rate_limiting"` Mail *mailReport `json:"mail"` } type mailReport struct { Checked string `json:"checked"` Results []struct { Zone string `json:"zone"` Address string `json:"address"` Roles []string `json:"roles"` PTR []string `json:"ptr"` OK bool `json:"ok"` Level string `json:"level"` Message string `json:"message"` ManagedBy string `json:"managed_by"` LocalZone string `json:"local_zone"` } `json:"results"` Errors []string `json:"errors"` } func printMail(w io.Writer, m *mailReport) { if len(m.Results) == 0 && len(m.Errors) == 0 { fmt.Fprintln(w, "No mail addresses: no public zone has an MX record or SPF addresses.") return } fmt.Fprintln(w, "ZONE\tADDRESS\tFOR\tPTR\tSTATUS") for _, r := range m.Results { ptr := strings.Join(r.PTR, ",") if ptr == "" { ptr = "-" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Zone, r.Address, strings.Join(r.Roles, ", "), ptr, r.Level) if r.Level != "ok" { fmt.Fprintf(w, " %s\n", r.Message) } if r.LocalZone != "" { fmt.Fprintf(w, " Jarl's reverse zone %s only answers inside: receivers ask %s\n", r.LocalZone, orOwner(r.ManagedBy)) } } for _, e := range m.Errors { fmt.Fprintf(w, "error: %s\n", e) } } func orOwner(s string) string { if s == "" { return "the address's owner" } return s } // exposureScope turns "server" (or "") into the server, anything else // into a view name. func exposureScope(s string) map[string]any { if s == "" || strings.EqualFold(s, "server") { return map[string]any{} } return map[string]any{"view": s} } func exposureCmd() *cobra.Command { cmd := &cobra.Command{ Use: "exposure", Short: "What is reachable from the internet, and what to harden (docs/exposure.md)", Long: `Shows which views (or the server, without views) are reachable from the internet and why, their rate limiting, and the exposure findings with the fix Jarl can stage for each.`, Example: ` jarl exposure jarl exposure fix external exposure_rate_limit # rate limiting, log-only first jarl exposure fix external exposure_rate_limit_log_only # then start enforcing jarl exposure fix server exposure_version jarl exposure ack external security_open_resolver --reason "public resolver for the club" jarl view update external --exposure public # override the classification`, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var r exposureReport if err := c.Get(ctx(), "/exposure", nil, &r); err != nil { return err } return output(r, func(w io.Writer) { fmt.Fprintln(w, "VIEW\tEXPOSURE\tRATE LIMIT\tWHY") for _, s := range r.Scopes { name, exp := s.View, "private" if name == "" { name = "(server)" } if s.Public { exp = "public" } if s.Override != "" { exp += " (set)" } rl := s.RateLimit if !s.Public && rl == "off" { rl = "-" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, exp, rl, s.Reason) } var all []exposureFinding for _, s := range r.Scopes { all = append(all, s.Findings...) } all = append(all, r.Server...) fmt.Fprintln(w) if len(all) == 0 { fmt.Fprintln(w, "No findings.") } else { fmt.Fprintln(w, "SEVERITY\tWHERE\tCODE\tFINDING") for _, f := range all { where := f.View if where == "" { where = "server" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Severity, where, f.Code, f.Message) if f.Fix != "" { fmt.Fprintf(w, "\t\t\tfix: jarl exposure fix %s %s (%s)\n", where, f.Code, f.Fix) } } } if r.Mail != nil { fmt.Fprintln(w, "\nMAIL REVERSE DNS") printMail(w, r.Mail) } if a := r.RateLimiting; a != nil && len(a.Counts) > 0 { fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since) for _, e := range a.Counts { fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count) } for _, e := range a.Networks { fmt.Fprintf(w, "network %s\t%d\n", e.Key, e.Count) } for _, e := range a.Names { fmt.Fprintf(w, "name %s\t%d\n", e.Key, e.Count) } } }) }, } fix := &cobra.Command{ Use: "fix VIEW|server CODE", Short: "Stage (or apply) the fix Jarl suggests for a finding", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } body := exposureScope(args[0]) body["code"] = args[1] resp, err := c.Mutate(ctx(), "POST", "/exposure/fix", nil, body) if err != nil { return err } return report(resp) }, } var reason string ack := &cobra.Command{ Use: "ack VIEW|server CODE --reason TEXT", Short: "Accept a finding on purpose: it stays, as info, with who and why", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } body := exposureScope(args[0]) body["code"], body["reason"] = args[1], reason resp, err := c.Mutate(ctx(), "POST", "/exposure/ack", nil, body) if err != nil { return err } return report(resp) }, } ack.Flags().StringVar(&reason, "reason", "", "why the finding is accepted (required)") unack := &cobra.Command{ Use: "unack VIEW|server CODE", Short: "Take an acknowledgement back: the finding warns again", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } body := exposureScope(args[0]) body["code"] = args[1] resp, err := c.Mutate(ctx(), "POST", "/exposure/unack", nil, body) if err != nil { return err } return report(resp) }, } mail := &cobra.Command{ Use: "mail", Short: "Check the reverse DNS of the addresses that send and receive your mail, now", Long: `Finds the addresses of the public zones' MX hosts and SPF records and checks, through public resolvers, that each has a PTR record whose name resolves back to it, as Gmail and Outlook do. Exits 1 when one doesn't.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := newClient() if err != nil { return err } var m mailReport if err := c.Do(ctx(), "POST", "/exposure/mail", nil, map[string]any{}, &m); err != nil { return err } if err := output(m, func(w io.Writer) { printMail(w, &m) }); err != nil { return err } for _, r := range m.Results { if !r.OK { return errors.New("some mail addresses have no working reverse DNS") } } return nil }, } cmd.AddCommand(fix, ack, unack, mail) return cmd }