From b88d355e4f9a9e61e9cf070773d4f9607a2cfa2b Mon Sep 17 00:00:00 2001 From: IdanK Date: Sat, 1 Aug 2026 19:11:09 +0300 Subject: [PATCH] Add full-target scan with live progress output Scan enumerates the target's subdomains and runs every installed module against each one, module by module, so a module can rely on its dependencies having reported before it runs. --- CHANGELOG.md | 13 ++ cmd/main.go | 5 +- internal/cmd/command.go | 5 + internal/cmd/help.go | 10 ++ internal/cmd/scan.go | 30 +++++ internal/dns/dns.go | 16 +++ internal/logging/log.go | 46 +++++++ internal/module/dns.go | 15 +++ internal/module/http.go | 9 +- internal/module/runner.go | 62 +++++---- internal/net/subdomain/finder.go | 54 +++++++- internal/scanning/render.go | 156 +++++++++++++++++++++ internal/scanning/scanner.go | 223 +++++++++++++++++++++++++++++++ internal/style/style.go | 21 +-- pkg/records/http.go | 1 - 15 files changed, 613 insertions(+), 53 deletions(-) create mode 100644 internal/cmd/scan.go create mode 100644 internal/logging/log.go create mode 100644 internal/scanning/render.go create mode 100644 internal/scanning/scanner.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1ea99..f95e1a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Module dependency graph resolution, so modules can depend on other modules and are loaded in the right order. - PDF summary output for network scans. +- `scan` command: enumerates the target's subdomains and runs every installed + module against each one, returning the whole run as JSON. The shell prints + it as an indented tree instead of raw JSON. +- Live scan progress with the usual `[+]`, `[-]`, and `[!]` markers, so a run + reports as it goes rather than only at the end. Added `internal/logging` for + the concurrency-safe output behind it, and wired it into the DNS and HTTP + modules. +- Scans skip subdomains that no longer resolve, so dead names left behind in + certificate transparency logs no longer cost a timeout per module. - Community and contribution scaffolding: `CONTRIBUTING.md`, issue templates, and a PR review request workflow. @@ -26,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `internal/style`, unifying how commands and modules print to the terminal. - Reworked DNS, HTTP, and subdomain record handling in `pkg/records` and `pkg/subdomains`. +- The HTTP module fetches its target once instead of twice: it checked the + site status and then refetched the same URL for the records. +- Subdomain enumeration during a scan is bounded by a timeout, so an + unresponsive certificate transparency source can no longer hang the run. - Documentation updates to `README.md` covering installation, module management, and usage. diff --git a/cmd/main.go b/cmd/main.go index dbf2b7c..8043973 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -79,9 +79,6 @@ func main() { func run(target string) error { printBanner() - if err := management.InstallModule(context.Background(), "https://github.com/GoScouter/nginx-module"); err != nil { - panic(err) - } if err := versions.SuggestUpdate(VERSION); err != nil { return fmt.Errorf("update check: %w", err) @@ -130,6 +127,8 @@ func run(target string) error { interrupted.Store(true) }() + runner.CleanupState() + reader := bufio.NewReader(os.Stdin) for !interrupted.Load() { fmt.Print(style.Prompt()) diff --git a/internal/cmd/command.go b/internal/cmd/command.go index ecced21..b336077 100644 --- a/internal/cmd/command.go +++ b/internal/cmd/command.go @@ -64,6 +64,11 @@ func NewManager(target string, manager *module.Manager) (*CommandManager, error) }) } + cm.addCommand(&ScanCommand{ + CmdManager: cm, + ModuleManager: manager, + }) + return cm, nil } diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 0108b24..3d6a64c 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -54,6 +54,16 @@ func (cmd *HelpCommand) Exec(args []string) error { style.Dim("Returns goscouter cli version"), )) + b.WriteString(fmt.Sprintf(" %s %s\r\n", + style.Cyan("--install"), + style.Dim("Installs an external module"), + )) + + b.WriteString(fmt.Sprintf(" %s %s\r\n", + style.Cyan("--uninstall"), + style.Dim("Uninstalls an external module"), + )) + fmt.Printf("%s\r\n", b.String()) return nil } diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go new file mode 100644 index 0000000..7cbb0ea --- /dev/null +++ b/internal/cmd/scan.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + "goscouter/internal/module" + "goscouter/internal/scanning" +) + +type ScanCommand struct { + CmdManager *CommandManager + ModuleManager *module.Manager +} + +func (cmd *ScanCommand) Name() string { + return "scan" +} + +func (cmd *ScanCommand) Description() string { + return "Makes a complete scan of the target" +} + +func (cmd *ScanCommand) Exec(args []string) error { + output, err := scanning.Scan(cmd.CmdManager.Target, cmd.ModuleManager) + if err != nil { + return err + } + + fmt.Print("\r\n" + scanning.Render(output)) + return nil +} diff --git a/internal/dns/dns.go b/internal/dns/dns.go index bf341be..7553885 100644 --- a/internal/dns/dns.go +++ b/internal/dns/dns.go @@ -11,6 +11,22 @@ import ( const TIMEOUT time.Duration = 5 * time.Second +const RESOLVE_TIMEOUT time.Duration = 2 * time.Second + +func Resolve(ctx context.Context, host string) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, RESOLVE_TIMEOUT) + defer cancel() + + addrs, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + return nil, err + } + if len(addrs) == 0 { + return nil, fmt.Errorf("no addresses for %s", host) + } + return addrs, nil +} + func Lookup(host string) (*records.DNSRecords, error) { ctx, cancel := context.WithTimeout(context.Background(), TIMEOUT) defer cancel() diff --git a/internal/logging/log.go b/internal/logging/log.go new file mode 100644 index 0000000..e1d87a0 --- /dev/null +++ b/internal/logging/log.go @@ -0,0 +1,46 @@ +package logging + +import ( + "fmt" + "io" + "os" + "sync" + + "goscouter/internal/style" +) + +var ( + mu sync.Mutex + out io.Writer = os.Stdout + enabled = true +) + +func SetOutput(w io.Writer) { + mu.Lock() + defer mu.Unlock() + out = w +} + +func SetEnabled(on bool) { + mu.Lock() + defer mu.Unlock() + enabled = on +} + +func line(s string) { + mu.Lock() + defer mu.Unlock() + + if !enabled { + return + } + fmt.Fprint(out, s+"\r\n") +} + +func Found(format string, a ...any) { line(style.Foundf(format, a...)) } + +func Failed(format string, a ...any) { line(style.Failuref(format, a...)) } + +func Alert(format string, a ...any) { line(style.Alertf(format, a...)) } + +func Step(format string, a ...any) { line(style.Infof(format, a...)) } diff --git a/internal/module/dns.go b/internal/module/dns.go index 8734d9c..4eda310 100644 --- a/internal/module/dns.go +++ b/internal/module/dns.go @@ -6,10 +6,12 @@ import ( "net/url" "goscouter/internal/dns" + "goscouter/internal/logging" "goscouter/internal/style" "goscouter/pkg/records" "github.com/GoScouter/sdk" + "golang.org/x/net/publicsuffix" ) type DnsModule struct{} @@ -24,6 +26,15 @@ func (m *DnsModule) Info() sdk.ModuleInfo { } func (m *DnsModule) Scout(target string, _ []string) (json.RawMessage, error) { + eTLDPlusOne, err := publicsuffix.EffectiveTLDPlusOne(target) + if err != nil { + return nil, fmt.Errorf("invalid domain") + } + + if target != eTLDPlusOne { + return nil, nil + } + parsed, err := url.Parse(target) if err != nil { return nil, fmt.Errorf("invalid target %q: %w", target, err) @@ -31,9 +42,13 @@ func (m *DnsModule) Scout(target string, _ []string) (json.RawMessage, error) { records, err := dns.Lookup(parsed.Path) if err != nil { + logging.Failed("dns: %s: %v", target, err) return nil, err } + logging.Found("dns: %s (%d A, %d AAAA, %d MX, %d NS, %d TXT)", + target, len(records.A), len(records.AAAA), len(records.MX), len(records.NS), len(records.TXT)) + data, err := json.Marshal(records) if err != nil { return nil, err diff --git a/internal/module/http.go b/internal/module/http.go index 036a693..a3566a5 100644 --- a/internal/module/http.go +++ b/internal/module/http.go @@ -7,6 +7,7 @@ import ( "net/url" "strings" + "goscouter/internal/logging" "goscouter/internal/style" "goscouter/internal/web" "goscouter/pkg/records" @@ -53,16 +54,14 @@ func (m *HttpModule) Scout(target string, args []string) (json.RawMessage, error target = forceScheme(target, "http") } - _, err := web.CheckSiteStatus(target) - if err != nil { - return nil, err - } - records, err := web.FetchHTTPRecords(target, scheme) if err != nil { + logging.Failed("http: %s: %v", target, err) return nil, err } + logging.Found("http: %s %s", target, records.Status) + data, err := json.Marshal(records) if err != nil { return nil, err diff --git a/internal/module/runner.go b/internal/module/runner.go index 137594d..584aa08 100644 --- a/internal/module/runner.go +++ b/internal/module/runner.go @@ -236,47 +236,53 @@ func RunInOrder(info sdk.ModuleInfo, target string, args []string, manager *Mana viewOutput := make([]string, 0, len(plan)) for _, key := range plan { - author, name, _ := strings.Cut(key, ":") - - ns := sdk.ModuleNamespace{Author: author, Name: name} + data, view, err := RunModule(target, args, key, manager, reporter) + if err != nil { + return nil, nil, err + } - if author != internalAuthor { - m := manager.GetExternal(key) - if m == nil { - return dataOutput, viewOutput, fmt.Errorf("unknown module %q", key) - } + dataOutput = append(dataOutput, data) + viewOutput = append(viewOutput, view) + } - data, view, err := m.Scout(context.Background(), target, args) - if err != nil { - return dataOutput, viewOutput, err - } + return dataOutput, viewOutput, nil +} - if err := reporter.Report(ns, data); err != nil { - return dataOutput, viewOutput, err - } +func RunModule(target string, args []string, key string, manager *Manager, reporter *Reporter) (json.RawMessage, string, error) { + author, name, _ := strings.Cut(key, ":") + ns := sdk.ModuleNamespace{Author: author, Name: name} - dataOutput = append(dataOutput, data) - viewOutput = append(viewOutput, view) - continue - } - - m := manager.GetInternal(key) + if ns.Author != internalAuthor { + m := manager.GetExternal(key) if m == nil { - return dataOutput, viewOutput, fmt.Errorf("unknown module %q", key) + return nil, "", fmt.Errorf("unknown module %q", key) } - data, err := m.Scout(target, args) + data, view, err := m.Scout(context.Background(), target, args) if err != nil { - return dataOutput, viewOutput, err + return nil, "", err } if err := reporter.Report(ns, data); err != nil { - return dataOutput, viewOutput, err + return nil, "", err } - dataOutput = append(dataOutput, data) - viewOutput = append(viewOutput, m.Render(data)) + return data, view, nil } - return dataOutput, viewOutput, nil + m := manager.GetInternal(key) + if m == nil { + return nil, "", fmt.Errorf("unknown module %q", key) + } + + data, err := m.Scout(target, args) + if err != nil { + return nil, "", err + } + + if err := reporter.Report(ns, data); err != nil { + return nil, "", err + } + + return data, m.Render(data), nil } diff --git a/internal/net/subdomain/finder.go b/internal/net/subdomain/finder.go index b34f01d..1623ff2 100644 --- a/internal/net/subdomain/finder.go +++ b/internal/net/subdomain/finder.go @@ -22,8 +22,9 @@ type Finder struct { } var Finders = map[string]Finder{ - "crtsh": {Name: "crtsh", Fetch: fetchCrtSh}, - "certspotter": {Name: "certspotter", Fetch: fetchCertSpotter}, + "crtsh": {Name: "crtsh", Fetch: fetchCrtSh}, + "certspotter": {Name: "certspotter", Fetch: fetchCertSpotter}, + "hackertarget": {Name: "hackertarget", Fetch: fetchHackerTarget}, } const TIMEOUT time.Duration = 5 * time.Second @@ -147,6 +148,55 @@ func fetchCertSpotter(ctx context.Context, domain string) ([]subdomains.Subdomai return flatten(latest), nil } +func fetchHackerTarget(ctx context.Context, domain string) ([]subdomains.Subdomain, error) { + rawURL := fmt.Sprintf("https://api.hackertarget.com/hostsearch/?q=%s", url.QueryEscape(domain)) + + req, err := newRequest(ctx, rawURL) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hackertarget returned status %d", resp.StatusCode) + } + + suffix := "." + normalize(domain) + + latest := make(map[string]time.Time) + scanner := bufio.NewScanner(resp.Body) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + host, _, ok := strings.Cut(line, ",") + if !ok { + return nil, fmt.Errorf("hackertarget: %s", line) + } + + host = normalize(host) + if host != normalize(domain) && !strings.HasSuffix(host, suffix) { + continue + } + + keepLatest(latest, host, time.Time{}) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading hackertarget response: %w", err) + } + + return flatten(latest), nil +} + type finderResult struct { source string subs []subdomains.Subdomain diff --git a/internal/scanning/render.go b/internal/scanning/render.go new file mode 100644 index 0000000..a5fbba6 --- /dev/null +++ b/internal/scanning/render.go @@ -0,0 +1,156 @@ +package scanning + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "goscouter/internal/style" +) + +const indentWidth = 4 + +func Render(raw json.RawMessage) string { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + + var b strings.Builder + if err := value(&b, dec, "", 0); err != nil { + return style.Failuref("scan: unreadable report: %v", err) + "\r\n" + } + + return b.String() +} + +func value(b *strings.Builder, dec *json.Decoder, label string, depth int) error { + tok, err := dec.Token() + if err != nil { + return err + } + + if d, ok := tok.(json.Delim); ok { + switch d { + case '{': + return object(b, dec, label, depth) + case '[': + return array(b, dec, label, depth) + default: + return fmt.Errorf("unexpected %q", d) + } + } + + field(b, depth, label, scalar(tok)) + return nil +} + +func object(b *strings.Builder, dec *json.Decoder, label string, depth int) error { + inner := depth + if label != "" { + heading(b, depth, label) + inner++ + } + + for dec.More() { + key, err := dec.Token() + if err != nil { + return err + } + + name, ok := key.(string) + if !ok { + return fmt.Errorf("object key is %T, not a string", key) + } + + if err := value(b, dec, name, inner); err != nil { + return err + } + } + + _, err := dec.Token() // closing brace + return err +} + +func array(b *strings.Builder, dec *json.Decoder, label string, depth int) error { + titled := false + i := 0 + + for dec.More() { + tok, err := dec.Token() + if err != nil { + return err + } + + d, nested := tok.(json.Delim) + if !nested { + if !titled { + heading(b, depth, label) + titled = true + } + + bullet(b, depth+1, scalar(tok)) + i++ + continue + } + + element := fmt.Sprintf("%s[%d]", label, i) + b.WriteString("\r\n") + + switch d { + case '{': + err = object(b, dec, element, depth) + case '[': + err = array(b, dec, element, depth) + default: + err = fmt.Errorf("unexpected %q", d) + } + if err != nil { + return err + } + + i++ + } + + if i == 0 { + heading(b, depth, label) + } + + _, err := dec.Token() // closing bracket + return err +} + +func indent(depth int) string { + return strings.Repeat(" ", depth*indentWidth) +} + +func heading(b *strings.Builder, depth int, label string) { + b.WriteString(indent(depth) + style.Found(style.Bold(style.Cyan(label))) + "\r\n") +} + +func field(b *strings.Builder, depth int, label, val string) { + line := style.Gray(label + ":") + if val != "" { + line += " " + style.White(val) + } + + b.WriteString(indent(depth) + style.Found(line) + "\r\n") +} + +func bullet(b *strings.Builder, depth int, val string) { + b.WriteString(indent(depth) + style.Gray("- ") + style.White(val) + "\r\n") +} + +func scalar(tok json.Token) string { + switch v := tok.(type) { + case nil: + return "null" + case string: + return v + case json.Number: + return v.String() + case bool: + return fmt.Sprintf("%t", v) + default: + return fmt.Sprintf("%v", v) + } +} diff --git a/internal/scanning/scanner.go b/internal/scanning/scanner.go new file mode 100644 index 0000000..e8880dd --- /dev/null +++ b/internal/scanning/scanner.go @@ -0,0 +1,223 @@ +package scanning + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "strings" + "sync" + "time" + + "goscouter/internal/dns" + "goscouter/internal/logging" + "goscouter/internal/module" + "goscouter/internal/net/subdomain" + "goscouter/internal/style" + "goscouter/pkg/subdomains" +) + +const ( + maxConcurrentProbes = 32 + maxConcurrentLookups = 64 +) + +type ModuleResult struct { + Module string `json:"module"` + Data json.RawMessage `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +type HostResult struct { + Host string `json:"host"` + LastSeen time.Time `json:"last_seen,omitzero"` + Addresses []string `json:"addresses,omitempty"` + Modules []ModuleResult `json:"modules"` +} + +type Result struct { + Target string `json:"target"` + Host string `json:"host"` + Found int `json:"found"` + Live int `json:"live"` + Hosts []HostResult `json:"hosts"` +} + +func hostOf(target string) string { + t := strings.TrimSpace(target) + t = strings.TrimPrefix(t, "*.") + + if strings.Contains(t, "://") { + if u, err := url.Parse(t); err == nil && u.Hostname() != "" { + return u.Hostname() + } + } + + // No scheme: url.Parse would shove everything into Path, so parse by hand. + if i := strings.IndexAny(t, "/?#"); i >= 0 { + t = t[:i] + } + if h, _, ok := strings.Cut(t, ":"); ok { + t = h + } + return t +} + +func Scan(target string, manager *module.Manager) (json.RawMessage, error) { + if manager == nil || manager.Graph == nil { + return nil, errors.New("scanning: module graph is not built") + } + + started := time.Now() + host := hostOf(target) + + order, err := manager.Graph.Order() + if err != nil { + return nil, err + } + + logging.Step("Enumerating subdomains of %s", style.Bold(host)) + + ctx, cancel := context.WithTimeout(context.Background(), subdomain.TIMEOUT) + defer cancel() + + found, err := subdomain.FindAll(ctx, target) + if err != nil { + return nil, err + } + + candidates := candidates(found, host) + logging.Step("%d unique subdomains, resolving", len(candidates)) + + hosts := resolve(context.Background(), candidates, order) + + result := Result{ + Target: target, + Host: host, + Found: len(candidates), + Live: len(hosts), + Hosts: hosts, + } + + if len(hosts) == 0 { + logging.Alert("No live hosts to probe") + return json.Marshal(result) + } + + malshan, err := module.DialReporter() + if err != nil { + return nil, err + } + defer malshan.Close() + + sem := make(chan struct{}, maxConcurrentProbes) + + for mi, m := range order { + logging.Step("Running %s against %d hosts", style.Bold(m), len(result.Hosts)) + + var wg sync.WaitGroup + + for hi := range result.Hosts { + wg.Add(1) + go func(slot *ModuleResult, name string, m string, reporter *module.Reporter) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + slot.Module = m + + data, _, err := module.RunModule(name, []string{}, m, manager, reporter) + if err != nil { + slot.Error = err.Error() + logging.Failed("%s %s: %v", pad(name), style.Gray(m), err) + return + } + + slot.Data = data + + if len(data) == 0 || string(data) == "null" { + logging.Alert("%s %s: nothing to report", pad(name), style.Gray(m)) + return + } + + logging.Found("%s %s", pad(name), style.Gray(m)) + }(&result.Hosts[hi].Modules[mi], result.Hosts[hi].Host, m, malshan) + } + + wg.Wait() + } + + logging.Step("Scan finished in %s", time.Since(started).Round(time.Millisecond)) + + return json.Marshal(result) +} + +func candidates(found []subdomains.Subdomain, host string) []subdomains.Subdomain { + out := make([]subdomains.Subdomain, 0, len(found)) + seen := make(map[string]bool, len(found)) + + for _, sub := range found { + name := hostOf(sub.Name) + if name == "" || name == host || seen[name] { + continue + } + seen[name] = true + + out = append(out, subdomains.Subdomain{Name: name, LastSeen: sub.LastSeen}) + } + + return out +} + +func resolve(ctx context.Context, candidates []subdomains.Subdomain, order []string) []HostResult { + type lookup struct { + addrs []string + err error + } + + results := make([]lookup, len(candidates)) + + sem := make(chan struct{}, maxConcurrentLookups) + var wg sync.WaitGroup + + for i, c := range candidates { + wg.Add(1) + go func(slot *lookup, name string) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + slot.addrs, slot.err = dns.Resolve(ctx, name) + }(&results[i], c.Name) + } + + wg.Wait() + + hosts := make([]HostResult, 0, len(candidates)) + for i, c := range candidates { + if results[i].err != nil { + logging.Failed("%s does not resolve", pad(c.Name)) + continue + } + + logging.Found("%s %s", pad(c.Name), style.Gray(strings.Join(results[i].addrs, ", "))) + + hosts = append(hosts, HostResult{ + Host: c.Name, + LastSeen: c.LastSeen, + Addresses: results[i].addrs, + Modules: make([]ModuleResult, len(order)), + }) + } + + return hosts +} + +func pad(name string) string { + if len(name) >= 38 { + return style.White(name) + } + return style.White(name) + strings.Repeat(" ", 38-len(name)) +} diff --git a/internal/style/style.go b/internal/style/style.go index 58fd620..7948a7d 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -42,9 +42,6 @@ func wrap(code, s string) string { func Bold(s string) string { return wrap(codeBold, s) } -// BoldAll makes an already-styled string bold across every color segment. -// Each color helper ends its span with a reset, which would also clear bold, so -// bold is re-asserted after each reset instead of just wrapping the whole line. func BoldAll(s string) string { if !enabled { return s @@ -65,8 +62,13 @@ func Prompt() string { return Dim("(") + Bold(Purple("gs")) + Dim(")") + " " + Cyan("❯") + " " } +func rawLines(msg string) string { + msg = strings.ReplaceAll(msg, "\r\n", "\n") + return strings.ReplaceAll(msg, "\n", "\r\n") +} + func Error(msg string) string { - return Red("✗ ") + msg + return Red("✗ ") + rawLines(msg) } func Errorf(format string, a ...any) string { @@ -91,15 +93,10 @@ func Infof(format string, a ...any) string { var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m") -// Width reports how wide a string prints, ignoring the color escapes in it. func Width(s string) int { return len([]rune(ansiRE.ReplaceAllString(s, ""))) } -// Module output uses the bracketed markers scanners conventionally print: -// [+] a result, [-] nothing found or a result that could not be read, and -// [!] something the user should know about but that does not stop the run. - func Found(msg string) string { return Green("[+] ") + msg } @@ -117,15 +114,13 @@ func Failuref(format string, a ...any) string { } func Alert(msg string) string { - return Yellow("[!] ") + msg + return Yellow("[!] ") + rawLines(msg) } func Alertf(format string, a ...any) string { return Alert(fmt.Sprintf(format, a...)) } -// Section renders a block of module output under a bracketed heading, e.g. -// "[DNS]". Lines are terminated for raw mode. func Section(title string, body ...string) string { var b strings.Builder @@ -139,8 +134,6 @@ func Section(title string, body ...string) string { return b.String() } -// Field renders an indented "label value" row, with the label padded to width -// so a run of them forms a column. func Field(label string, width int, value string) string { return " " + Gray(fmt.Sprintf("%-*s", width, label)) + " " + value } diff --git a/pkg/records/http.go b/pkg/records/http.go index 5314326..811dbca 100644 --- a/pkg/records/http.go +++ b/pkg/records/http.go @@ -38,7 +38,6 @@ func (r *HTTPRecords) Render() string { return style.Section(r.Scheme, lines...) } -// field renders one " Label : value" row. func field(name, value string) string { return label(name) + " " + value }