jarld checks the reverse DNS of the public zones' MX and SPF addresses as the internet sees it, every 6 hours (Exposure page, jarl exposure mail, jarl_mail_reverse_dns_ok). The Server options page, jarl option generated and GET /options/generated list what Jarl writes into named.conf by itself; stats.rate_limit_log turns off the query-errors logging. Option lists of three or more elements are shown one per line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
7.5 KiB
Go
272 lines
7.5 KiB
Go
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
|
|
}
|