From 72928ab19bdbc6d7f91ca82c08a952c9aff84c9a Mon Sep 17 00:00:00 2001 From: IdanK Date: Sat, 1 Aug 2026 01:19:22 +0300 Subject: [PATCH 1/2] Fix logging method & Subdomain finder fix --- cmd/main.go | 79 ++++++++++++++------------------ internal/cmd/clear_test.go | 8 ---- internal/cmd/command.go | 6 +-- internal/cmd/info.go | 13 ++---- internal/cmd/info_test.go | 10 ---- internal/cmd/target.go | 3 +- internal/logger/log.go | 76 ------------------------------ internal/logger/log_test.go | 57 ----------------------- internal/module/dns.go | 8 ++-- internal/module/graph.go | 4 +- internal/module/http.go | 5 +- internal/module/manager.go | 11 ++--- internal/module/runner.go | 21 +++++++-- internal/module/subdomains.go | 30 +++++------- internal/style/style.go | 59 +++++++++++++++++++++++- internal/style/style_test.go | 51 +++++++++++++++++++-- internal/versions/version.go | 3 +- internal/web/validation_test.go | 10 ---- pkg/records/dns.go | 49 ++++++++++++-------- pkg/records/dns_test.go | 30 +++++++----- pkg/records/http.go | 67 +++++++++++++++++++++------ pkg/records/http_test.go | 9 ++++ pkg/subdomains/subdomain.go | 27 ++++++++++- pkg/subdomains/subdomain_test.go | 27 +++++++++++ 24 files changed, 350 insertions(+), 313 deletions(-) delete mode 100644 internal/logger/log.go delete mode 100644 internal/logger/log_test.go diff --git a/cmd/main.go b/cmd/main.go index cd9fadd..933c0c3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,7 +7,6 @@ import ( "flag" "fmt" "io" - "log/slog" "os" "os/signal" "strings" @@ -16,7 +15,6 @@ import ( "goscouter/internal" "goscouter/internal/cmd" - "goscouter/internal/logger" "goscouter/internal/module" "goscouter/internal/style" "goscouter/internal/terminal" @@ -37,70 +35,61 @@ func main() { flag.Parse() if *version { - fmt.Println("Version:", VERSION) - os.Exit(0) + fmt.Println(versionString()) + return } if *targetSite == "" { - fmt.Println("Usage: gs --target ") + fmt.Fprintln(os.Stderr, "Usage: gs --target ") os.Exit(1) } + if err := run(*targetSite); err != nil { + fmt.Fprintf(os.Stderr, "%s\r\n", style.Error(err.Error())) + os.Exit(1) + } +} + +func run(target string) error { printBanner() - err := logger.SetupLogger(logger.LoggerConfig{ - Console: false, - Level: slog.LevelInfo, - }) - if err != nil { - panic(err) + if err := versions.SuggestUpdate(VERSION); err != nil { + return fmt.Errorf("update check: %w", err) } - if err = versions.SuggestUpdate(VERSION); err != nil { - logger.Log.Warn("Update check failed", "error", err) - fmt.Printf("%s\n\n", style.Error("Update: "+err.Error())) - return - } + fmt.Printf("%s %s\n\n", style.Gray("Target:"), style.Bold(target)) - fmt.Printf("%s %s\n\n", style.Gray("Target:"), style.Bold(*targetSite)) - logger.Log.Info("Entering terminal raw mode") state, err := terminal.NewShellState() if err != nil { - panic(err) + return err } + defer state.Restore() - logger.Log.Info("Loading modules") moduleManager := module.NewManager() - - if err = moduleManager.LoadExternals(context.Background()); err != nil { - panic(err) + if err := moduleManager.LoadExternals(context.Background()); err != nil { + return err } - logger.Log.Info("Building module dependency graph") - graph, err := moduleManager.Build() - if err != nil { - logger.Log.Warn("Module dependency graph is incomplete", "error", err) - fmt.Printf("%s\n", style.Error("Modules: "+err.Error())) - } - if order, err := graph.Order(); err == nil { - logger.Log.Info("Module run order resolved", "order", strings.Join(order, " -> ")) + // An incomplete graph only disables the modules that depend on what is + // missing, so report it and keep going. + if _, err := moduleManager.Build(); err != nil { + fmt.Printf("%s\r\n", style.Alertf("modules: %v", err)) } runner, err := module.CreateRunner() if err != nil { - panic(err) + return err } go func() { if err := runner.Start(context.Background()); err != nil { - panic(err) + fmt.Fprintf(os.Stderr, "%s\r\n", style.Errorf("runner: %v", err)) } }() - logger.Log.Info("Starting command manager") - commandManager, err := cmd.NewManager(*targetSite, moduleManager) + commandManager, err := cmd.NewManager(target, moduleManager) if err != nil { - panic(err) + return err } sigChan := make(chan os.Signal, 1) @@ -155,8 +144,15 @@ func main() { runner.CleanupState() } - logger.Log.Info("Exiting terminal raw mode, restoring old state") - defer state.Restore() + return nil +} + +// versionString falls back to "dev" for builds made without the release ldflags. +func versionString() string { + if VERSION == "" { + return "dev" + } + return VERSION } func printBanner() { @@ -165,12 +161,7 @@ func printBanner() { buildTime = "unknown" } internal.BuildTime = buildTime - - version := VERSION - if version == "" { - version = "dev" - } - internal.Version = version + internal.Version = versionString() utils.PrintBanner(internal.Version, internal.BuildTime) } diff --git a/internal/cmd/clear_test.go b/internal/cmd/clear_test.go index 493e31f..3b70794 100644 --- a/internal/cmd/clear_test.go +++ b/internal/cmd/clear_test.go @@ -2,19 +2,11 @@ package cmd import ( "io" - "log/slog" "os" "strings" "testing" - - "goscouter/internal/logger" ) -func TestMain(m *testing.M) { - logger.Log = slog.New(slog.NewTextHandler(io.Discard, nil)) - os.Exit(m.Run()) -} - func captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/internal/cmd/command.go b/internal/cmd/command.go index b0edab6..ecced21 100644 --- a/internal/cmd/command.go +++ b/internal/cmd/command.go @@ -2,13 +2,12 @@ package cmd import ( "fmt" - "goscouter/internal/module" "maps" "regexp" "slices" "strings" - "goscouter/internal/logger" + "goscouter/internal/module" ) type Command interface { @@ -32,15 +31,12 @@ func NewManager(target string, manager *module.Manager) (*CommandManager, error) Target: target, } - logger.Log.Info("Loading built-in commands") cm.addCommand(&InfoCommand{}) cm.addCommand(&ExitCommand{}) cm.addCommand(&ClearCommand{}) cm.addCommand(&HelpCommand{Manager: cm}) cm.addCommand(&TargetCommand{Manager: cm}) - logger.Log.Info("Loaded built-in commands.") - if manager == nil { return cm, nil } diff --git a/internal/cmd/info.go b/internal/cmd/info.go index f68ae79..417d17f 100644 --- a/internal/cmd/info.go +++ b/internal/cmd/info.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "regexp" "runtime" "strings" @@ -47,12 +46,6 @@ var logo = []string{ style.Yellow(" .-==- ") + style.Cyan(".--=======--- ") + style.Yellow(".==-"), } -var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m") - -func visibleWidth(s string) int { - return len([]rune(ansiRE.ReplaceAllString(s, ""))) -} - func field(key, value string) string { return style.Bold(style.White(key)) + style.Gray(" : ") + value } @@ -65,7 +58,7 @@ func colorSwatch() string { func infoLines() []string { title := style.Bold(style.Cyan("GoScouter")) - rule := style.Gray(strings.Repeat("─", visibleWidth(title)+9)) + rule := style.Gray(strings.Repeat("─", style.Width(title)+9)) return []string{ title, @@ -94,7 +87,7 @@ func (cmd *InfoCommand) Exec(args []string) error { logoWidth := 0 for _, line := range logo { - if w := visibleWidth(line); w > logoWidth { + if w := style.Width(line); w > logoWidth { logoWidth = w } } @@ -113,7 +106,7 @@ func (cmd *InfoCommand) Exec(args []string) error { logoLine = style.BoldAll(logo[i]) } - padding := logoWidth + gap - visibleWidth(logoLine) + padding := logoWidth + gap - style.Width(logoLine) if padding < 0 { padding = 0 } diff --git a/internal/cmd/info_test.go b/internal/cmd/info_test.go index cb3374d..8bc485f 100644 --- a/internal/cmd/info_test.go +++ b/internal/cmd/info_test.go @@ -45,13 +45,3 @@ func TestInfoCommandExec(t *testing.T) { } } } - -func TestVisibleWidthStripsANSI(t *testing.T) { - styled := "\x1b[31mabc\x1b[0m" - if got := visibleWidth(styled); got != 3 { - t.Fatalf("expected visible width 3, got %d", got) - } - if got := visibleWidth("héllo"); got != 5 { - t.Fatalf("expected visible width 5 for multibyte string, got %d", got) - } -} diff --git a/internal/cmd/target.go b/internal/cmd/target.go index 833cdfb..abe31db 100644 --- a/internal/cmd/target.go +++ b/internal/cmd/target.go @@ -2,7 +2,7 @@ package cmd import ( "fmt" - "goscouter/internal/logger" + "goscouter/internal/style" ) @@ -35,7 +35,6 @@ func (cmd *TargetCommand) Exec(args []string) error { cmd.Manager.SetTarget(target) - logger.Log.Info(fmt.Sprintf("Target set to %q", target)) fmt.Printf("%s\r\n", style.Successf("Target set to %s", style.Bold(target))) return nil } diff --git a/internal/logger/log.go b/internal/logger/log.go deleted file mode 100644 index f237611..0000000 --- a/internal/logger/log.go +++ /dev/null @@ -1,76 +0,0 @@ -package logger - -import ( - "io" - "log/slog" - "os" - "path/filepath" -) - -type LoggerConfig struct { - Console bool - Level slog.Level -} - -var Log *slog.Logger - -// logFile is the file handle backing Log, retained so it can be closed. -var logFile *os.File - -// Close releases the log file handle. Safe to call when the logger was never -// set up. On Windows an open handle prevents the file from being removed, so -// tests (and any short-lived setup) must call this before cleanup. -func Close() error { - if logFile == nil { - return nil - } - err := logFile.Close() - logFile = nil - return err -} - -func LogPath() (string, error) { - dir, err := os.UserHomeDir() - if err != nil { - return "", err - } - - dir = filepath.Join(dir, "goscouter") - if err := os.MkdirAll(dir, 0755); err != nil { - return "", err - } - - return filepath.Join(dir, "goscouter.log"), nil -} - -func SetupLogger(cfg LoggerConfig) error { - logPath, err := LogPath() - if err != nil { - return err - } - - file, err := os.OpenFile( - logPath, - os.O_CREATE|os.O_WRONLY|os.O_APPEND, - 0644, - ) - if err != nil { - return err - } - - var writer io.Writer = file - if cfg.Console { - writer = io.MultiWriter(os.Stdout, file) - } - - opts := &slog.HandlerOptions{ - Level: cfg.Level, - AddSource: true, - } - - handler := slog.NewTextHandler(writer, opts) - Log = slog.New(handler) - logFile = file - - return nil -} diff --git a/internal/logger/log_test.go b/internal/logger/log_test.go deleted file mode 100644 index c58c78e..0000000 --- a/internal/logger/log_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package logger - -import ( - "log/slog" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLogPath(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) // os.UserHomeDir uses USERPROFILE on Windows - - got, err := LogPath() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if filepath.Base(got) != "goscouter.log" { - t.Fatalf("expected file goscouter.log, got %q", got) - } - if !strings.HasSuffix(filepath.Dir(got), "goscouter") { - t.Fatalf("expected path under goscouter dir, got %q", got) - } - - if info, err := os.Stat(filepath.Dir(got)); err != nil || !info.IsDir() { - t.Fatalf("expected log directory to exist, err=%v", err) - } -} - -func TestSetupLogger(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) // os.UserHomeDir uses USERPROFILE on Windows - - if err := SetupLogger(LoggerConfig{Console: false, Level: slog.LevelInfo}); err != nil { - t.Fatalf("unexpected error: %v", err) - } - // Windows cannot remove a file while a handle is open; release it before - // t.TempDir's cleanup runs so RemoveAll succeeds. - defer Close() - if Log == nil { - t.Fatal("expected Log to be initialized") - } - - Log.Info("hello from test", "key", "value") - - data, err := os.ReadFile(filepath.Join(home, "goscouter", "goscouter.log")) - if err != nil { - t.Fatalf("reading log file: %v", err) - } - if !strings.Contains(string(data), "hello from test") { - t.Fatalf("expected log message in file, got %q", string(data)) - } -} diff --git a/internal/module/dns.go b/internal/module/dns.go index 804c55a..8734d9c 100644 --- a/internal/module/dns.go +++ b/internal/module/dns.go @@ -3,10 +3,11 @@ package module import ( "encoding/json" "fmt" + "net/url" + "goscouter/internal/dns" + "goscouter/internal/style" "goscouter/pkg/records" - "log" - "net/url" "github.com/GoScouter/sdk" ) @@ -44,8 +45,7 @@ func (m *DnsModule) Scout(target string, _ []string) (json.RawMessage, error) { func (m *DnsModule) Render(raw json.RawMessage) string { var dns records.DNSRecords if err := json.Unmarshal(raw, &dns); err != nil { - log.Print(err.Error()) - return "" + return style.Failuref("dns: unreadable results: %v\r\n", err) } return dns.Render() diff --git a/internal/module/graph.go b/internal/module/graph.go index bcada16..25c85a5 100644 --- a/internal/module/graph.go +++ b/internal/module/graph.go @@ -27,7 +27,7 @@ func BuildGraph(infos []sdk.ModuleInfo) (*Graph, error) { g := &Graph{sorter: topsort.NewGraph(), known: make(map[string]bool, len(infos))} for _, info := range infos { - if info.Author == internalAuthor && info.Name == "subdomains" { + if info.Author == internalAuthor && info.Name == SdModuleInfo.Name { continue } g.known[Key(Namespace(info))] = true @@ -36,7 +36,7 @@ func BuildGraph(infos []sdk.ModuleInfo) (*Graph, error) { var problems []error for _, info := range infos { - if info.Author == internalAuthor && info.Name == "subdomains" { + if info.Author == internalAuthor && info.Name == SdModuleInfo.Name { continue } diff --git a/internal/module/http.go b/internal/module/http.go index 126a8c5..036a693 100644 --- a/internal/module/http.go +++ b/internal/module/http.go @@ -4,10 +4,10 @@ import ( "encoding/json" "flag" "io" - "log" "net/url" "strings" + "goscouter/internal/style" "goscouter/internal/web" "goscouter/pkg/records" @@ -74,8 +74,7 @@ func (m *HttpModule) Scout(target string, args []string) (json.RawMessage, error func (m *HttpModule) Render(raw json.RawMessage) string { var r records.HTTPRecords if err := json.Unmarshal(raw, &r); err != nil { - log.Print(err.Error()) - return "" + return style.Failuref("http: unreadable results: %v\r\n", err) } return r.Render() diff --git a/internal/module/manager.go b/internal/module/manager.go index 777136c..98c1358 100644 --- a/internal/module/manager.go +++ b/internal/module/manager.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "goscouter/internal/utils" "maps" "os" "path/filepath" @@ -13,7 +12,8 @@ import ( "sync" "time" - "goscouter/internal/logger" + "goscouter/internal/style" + "goscouter/internal/utils" "github.com/GoScouter/sdk" ) @@ -248,9 +248,8 @@ func executable(entry os.DirEntry) bool { return info.Mode().Perm()&0o111 != 0 } +// warn reports a problem the user can act on — a module that could not be +// loaded, or one that would not shut down — without aborting the session. func warn(msg string) { - if logger.Log == nil { - return - } - logger.Log.Warn(msg) + fmt.Fprintf(os.Stderr, "%s\r\n", style.Alert(msg)) } diff --git a/internal/module/runner.go b/internal/module/runner.go index 81d8df4..137594d 100644 --- a/internal/module/runner.go +++ b/internal/module/runner.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log" "net" "os" "strings" @@ -93,7 +92,7 @@ func (r *Runner) Start(ctx context.Context) error { if ctx.Err() != nil { return nil } - log.Printf("runner: accept: %v", err) + warn(fmt.Sprintf("runner: accept: %v", err)) continue } @@ -111,13 +110,13 @@ func (r *Runner) handleClient(conn net.Conn) { var req sdk.Request if err := dec.Decode(&req); err != nil { if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { - log.Printf("runner: decode request: %v", err) + warn(fmt.Sprintf("runner: decode request: %v", err)) } return } if err := r.handle(enc, &req); err != nil { - log.Printf("runner: %q request: %v", req.Type, err) + warn(fmt.Sprintf("runner: %q request: %v", req.Type, err)) } } } @@ -214,6 +213,20 @@ func (r *Reporter) Report(namespace sdk.ModuleNamespace, data json.RawMessage) e } func RunInOrder(info sdk.ModuleInfo, target string, args []string, manager *Manager, reporter *Reporter) ([]json.RawMessage, []string, error) { + if Namespace(info) == Namespace(SdModuleInfo) { + sd := manager.GetInternal(Key(Namespace(SdModuleInfo))) + data, err := sd.Scout(target, args) + if err != nil { + return nil, nil, err + } + + if err := reporter.Report(Namespace(SdModuleInfo), data); err != nil { + return nil, nil, err + } + + return []json.RawMessage{data}, []string{sd.Render(data)}, nil + } + plan, err := manager.Graph.Plan(Key(Namespace(info))) if err != nil { return nil, nil, err diff --git a/internal/module/subdomains.go b/internal/module/subdomains.go index 4e50bac..a04ba5d 100644 --- a/internal/module/subdomains.go +++ b/internal/module/subdomains.go @@ -3,11 +3,9 @@ package module import ( "context" "encoding/json" - "fmt" - "log" - "strings" "goscouter/internal/net/subdomain" + "goscouter/internal/style" pkg "goscouter/pkg/subdomains" "github.com/GoScouter/sdk" @@ -15,18 +13,18 @@ import ( type SubdomainsModule struct{} +var SdModuleInfo = sdk.ModuleInfo{ + Name: "subdomains", + Author: internalAuthor, + Description: "Gather the subdomains of the target domain.", + Dependencies: make([]sdk.ModuleNamespace, 0), +} + func (m *SubdomainsModule) Info() sdk.ModuleInfo { - return sdk.ModuleInfo{ - Name: "subdomains", - Author: internalAuthor, - Description: "Gather the subdomains of the target domain.", - Dependencies: make([]sdk.ModuleNamespace, 0), - } + return SdModuleInfo } func (m *SubdomainsModule) Scout(target string, _ []string) (json.RawMessage, error) { - fmt.Printf("» subdomains: enumerating %s\r\n", target) - ctx, cancel := context.WithTimeout(context.Background(), subdomain.TIMEOUT) defer cancel() @@ -50,14 +48,8 @@ type subdomainResults struct { func (m *SubdomainsModule) Render(raw json.RawMessage) string { var results subdomainResults if err := json.Unmarshal(raw, &results); err != nil { - log.Print(err.Error()) - return "" + return style.Failuref("subdomains: unreadable results: %v\r\n", err) } - var b strings.Builder - for _, s := range results.Subs { - b.WriteString(s.Render()) - b.WriteString("\r\n") - } - return b.String() + return pkg.RenderAll(results.Subs) } diff --git a/internal/style/style.go b/internal/style/style.go index 1812fea..58fd620 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -3,6 +3,7 @@ package style import ( "fmt" "os" + "regexp" "strings" "golang.org/x/term" @@ -39,7 +40,7 @@ func wrap(code, s string) string { return code + s + reset } -func Bold(s string) string { return wrap(codeBold, s) } +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 @@ -87,3 +88,59 @@ func Info(msg string) string { func Infof(format string, a ...any) string { return Info(fmt.Sprintf(format, a...)) } + +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 +} + +func Foundf(format string, a ...any) string { + return Found(fmt.Sprintf(format, a...)) +} + +func Failure(msg string) string { + return Red("[-] ") + msg +} + +func Failuref(format string, a ...any) string { + return Failure(fmt.Sprintf(format, a...)) +} + +func Alert(msg string) string { + return Yellow("[!] ") + 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 + + b.WriteString("\r\n") + b.WriteString(Bold(Cyan("["+title+"]")) + "\r\n") + for _, line := range body { + b.WriteString(line + "\r\n") + } + b.WriteString("\r\n") + + 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/internal/style/style_test.go b/internal/style/style_test.go index 9c8be9b..1aa49dd 100644 --- a/internal/style/style_test.go +++ b/internal/style/style_test.go @@ -37,9 +37,12 @@ func TestEnabledWraps(t *testing.T) { func TestSemanticPrefixes(t *testing.T) { withEnabled(t, false, func() { cases := map[string]string{ - "✗ ": Error("x"), - "✓ ": Success("x"), - "» ": Info("x"), + "✗ ": Error("x"), + "✓ ": Success("x"), + "» ": Info("x"), + "[+] ": Found("x"), + "[-] ": Failure("x"), + "[!] ": Alert("x"), } for prefix, out := range cases { if !strings.HasPrefix(out, prefix) { @@ -48,3 +51,45 @@ func TestSemanticPrefixes(t *testing.T) { } }) } + +func TestWidthStripsANSI(t *testing.T) { + if got := Width("\x1b[31mabc\x1b[0m"); got != 3 { + t.Fatalf("Width of styled %q = %d, want 3", "abc", got) + } + if got := Width("héllo"); got != 5 { + t.Fatalf("Width of multibyte string = %d, want 5", got) + } +} + +func TestSectionBracketsTheTitle(t *testing.T) { + withEnabled(t, false, func() { + body := " A 1.2.3.4" + out := Section("DNS", body) + + lines := strings.Split(strings.TrimSpace(out), "\r\n") + if lines[0] != "[DNS]" { + t.Fatalf("first line = %q, want a bracketed title", lines[0]) + } + if lines[1] != body { + t.Fatalf("body line = %q, want it rendered verbatim", lines[1]) + } + }) +} + +func TestSectionPadsWithBlankLines(t *testing.T) { + withEnabled(t, false, func() { + out := Section("DNS") + + if !strings.HasPrefix(out, "\r\n") || !strings.HasSuffix(out, "\r\n\r\n") { + t.Fatalf("Section = %q, want it padded away from the surrounding output", out) + } + }) +} + +func TestFieldPadsLabel(t *testing.T) { + withEnabled(t, false, func() { + if got := Field("A", 6, "1.2.3.4"); got != " A 1.2.3.4" { + t.Fatalf("Field = %q, want the label padded into a column", got) + } + }) +} diff --git a/internal/versions/version.go b/internal/versions/version.go index e8069d0..067a7ac 100644 --- a/internal/versions/version.go +++ b/internal/versions/version.go @@ -14,7 +14,6 @@ import ( "strings" "time" - "goscouter/internal/logger" "goscouter/internal/style" "github.com/google/go-github/github" @@ -213,8 +212,8 @@ func install(staged, exe string) error { } func SuggestUpdate(current string) error { + // Unversioned builds have nothing to compare against. if current == "" || current == "dev" { - logger.Log.Info("Skipping update check for an unversioned build") return nil } diff --git a/internal/web/validation_test.go b/internal/web/validation_test.go index 0ed3988..4d36e75 100644 --- a/internal/web/validation_test.go +++ b/internal/web/validation_test.go @@ -1,21 +1,11 @@ package web import ( - "io" - "log/slog" "net/http" "net/http/httptest" - "os" "testing" - - "goscouter/internal/logger" ) -func TestMain(m *testing.M) { - logger.Log = slog.New(slog.NewTextHandler(io.Discard, nil)) - os.Exit(m.Run()) -} - func TestCheckSiteStatus(t *testing.T) { tests := []struct { name string diff --git a/pkg/records/dns.go b/pkg/records/dns.go index 6ad8f7c..9647cd7 100644 --- a/pkg/records/dns.go +++ b/pkg/records/dns.go @@ -1,8 +1,7 @@ package records import ( - "fmt" - "strings" + "goscouter/internal/style" ) type DNSRecords struct { @@ -15,30 +14,44 @@ type DNSRecords struct { TXT []string `json:"txt"` } +const dnsLabelWidth = 6 + func (r *DNSRecords) Render() string { - var b strings.Builder + sets := []struct { + label string + values []string + }{ + {"A", r.A}, + {"AAAA", r.AAAA}, + {"CNAME", nonEmpty(r.CNAME)}, + {"MX", r.MX}, + {"NS", r.NS}, + {"TXT", r.TXT}, + } - b.WriteString("\r\n[DNS]\r\n") - writeRecordSet(&b, "A", r.A) - writeRecordSet(&b, "AAAA", r.AAAA) - if r.CNAME != "" { - writeRecordSet(&b, "CNAME", []string{r.CNAME}) + lines := make([]string, 0, len(sets)) + for _, set := range sets { + lines = append(lines, recordSet(set.label, set.values)...) } - writeRecordSet(&b, "MX", r.MX) - writeRecordSet(&b, "NS", r.NS) - writeRecordSet(&b, "TXT", r.TXT) + if len(lines) == 0 { + lines = append(lines, style.Failure("no records found")) + } - b.WriteString("\r\n") - return b.String() + return style.Section("DNS", lines...) } -func writeRecordSet(b *strings.Builder, label string, values []string) { - if len(values) == 0 { - return +func recordSet(label string, values []string) []string { + lines := make([]string, 0, len(values)) + for _, v := range values { + lines = append(lines, style.Field(label, dnsLabelWidth, style.White(v))) } + return lines +} - for _, v := range values { - fmt.Fprintf(b, " %-6s %s\r\n", label, v) +func nonEmpty(v string) []string { + if v == "" { + return nil } + return []string{v} } diff --git a/pkg/records/dns_test.go b/pkg/records/dns_test.go index e46c580..311898e 100644 --- a/pkg/records/dns_test.go +++ b/pkg/records/dns_test.go @@ -54,7 +54,10 @@ func TestDNSRecordsRenderEmpty(t *testing.T) { out := r.Render() if !strings.Contains(out, "[DNS]") { - t.Errorf("Render() should still contain the [DNS] header, got:\n%s", out) + t.Errorf("Render() should still contain the [DNS] heading, got:\n%s", out) + } + if !strings.Contains(out, "[-] no records found") { + t.Errorf("Render() should say so when there is nothing to show, got:\n%s", out) } for _, label := range []string{" A ", " AAAA ", " CNAME ", " MX ", " NS ", " TXT "} { if strings.Contains(out, label) { @@ -63,19 +66,22 @@ func TestDNSRecordsRenderEmpty(t *testing.T) { } } -func TestWriteRecordSetSkipsEmpty(t *testing.T) { - var b strings.Builder - writeRecordSet(&b, "A", nil) - if b.Len() != 0 { - t.Errorf("writeRecordSet with empty values wrote %q, want nothing", b.String()) +func TestRecordSetSkipsEmpty(t *testing.T) { + if lines := recordSet("A", nil); len(lines) != 0 { + t.Errorf("recordSet with empty values returned %q, want nothing", lines) } } -func TestWriteRecordSetFormat(t *testing.T) { - var b strings.Builder - writeRecordSet(&b, "A", []string{"1.2.3.4"}) - want := " A 1.2.3.4\r\n" - if b.String() != want { - t.Errorf("writeRecordSet = %q, want %q", b.String(), want) +func TestRecordSetFormat(t *testing.T) { + lines := recordSet("A", []string{"1.2.3.4", "5.6.7.8"}) + + want := []string{" A 1.2.3.4", " A 5.6.7.8"} + if len(lines) != len(want) { + t.Fatalf("recordSet returned %d lines, want %d", len(lines), len(want)) + } + for i := range want { + if lines[i] != want[i] { + t.Errorf("recordSet line %d = %q, want %q", i, lines[i], want[i]) + } } } diff --git a/pkg/records/http.go b/pkg/records/http.go index 49380d5..5314326 100644 --- a/pkg/records/http.go +++ b/pkg/records/http.go @@ -5,6 +5,8 @@ import ( "net/http" "sort" "strings" + + "goscouter/internal/style" ) type HTTPRecords struct { @@ -17,31 +19,66 @@ type HTTPRecords struct { Headers http.Header `json:"headers"` } +const httpLabelWidth = 9 + func (r *HTTPRecords) Render() string { - var b strings.Builder + lines := []string{ + field("Status", statusColor(r.StatusCode, r.Status)), + field("Protocol", style.White(r.Proto)), + } - fmt.Fprintf(&b, "\r\n[%s]\r\n", r.Scheme) - fmt.Fprintf(&b, " Status : %s\r\n", r.Status) - fmt.Fprintf(&b, " Protocol : %s\r\n", r.Proto) if r.FinalURL != "" && r.FinalURL != r.RequestURL { - fmt.Fprintf(&b, " Redirect : %s -> %s\r\n", r.RequestURL, r.FinalURL) + redirect := style.White(r.RequestURL) + style.Gray(" -> ") + style.White(r.FinalURL) + lines = append(lines, field("Redirect", redirect)) } - b.WriteString(" Headers :\r\n") - keys := make([]string, 0, len(r.Headers)) - for k := range r.Headers { - keys = append(keys, k) + lines = append(lines, label("Headers")) + lines = append(lines, headerLines(r.Headers)...) + + return style.Section(r.Scheme, lines...) +} + +// field renders one " Label : value" row. +func field(name, value string) string { + return label(name) + " " + value +} + +func label(name string) string { + return " " + style.Gray(fmt.Sprintf("%-*s:", httpLabelWidth, name)) +} + +func headerLines(headers http.Header) []string { + if len(headers) == 0 { + return []string{" " + style.Dim("(none)")} } - sort.Strings(keys) - if len(keys) == 0 { - b.WriteString(" (none)\r\n") + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) for _, k := range keys { - fmt.Fprintf(&b, " %s: %s\r\n", k, strings.Join(r.Headers[k], ", ")) + value := style.White(strings.Join(headers[k], ", ")) + lines = append(lines, " "+style.Gray(k+":")+" "+value) } + return lines +} - b.WriteString("\r\n") - return b.String() +// statusColor tints the status line by response class so a failure stands out +// without having to read the code. +func statusColor(code int, status string) string { + switch { + case code >= 200 && code < 300: + return style.Green(status) + case code >= 300 && code < 400: + return style.Cyan(status) + case code >= 400 && code < 500: + return style.Yellow(status) + case code >= 500: + return style.Red(status) + default: + return style.White(status) + } } diff --git a/pkg/records/http_test.go b/pkg/records/http_test.go index 234d713..d0ba04e 100644 --- a/pkg/records/http_test.go +++ b/pkg/records/http_test.go @@ -107,3 +107,12 @@ func TestHTTPRecordsRenderNoHeaders(t *testing.T) { t.Errorf("Render() should print (none) when there are no headers\ngot:\n%s", r.Render()) } } + +func TestStatusColorByClass(t *testing.T) { + // Styling is off in tests, so this only checks the status survives intact. + for _, code := range []int{0, 200, 301, 404, 500} { + if got := statusColor(code, "status"); got != "status" { + t.Errorf("statusColor(%d) = %q, want the status text unchanged", code, got) + } + } +} diff --git a/pkg/subdomains/subdomain.go b/pkg/subdomains/subdomain.go index 3590544..6d50ea7 100644 --- a/pkg/subdomains/subdomain.go +++ b/pkg/subdomains/subdomain.go @@ -1,8 +1,10 @@ package subdomains import ( - "fmt" + "strings" "time" + + "goscouter/internal/style" ) type Subdomain struct { @@ -11,5 +13,26 @@ type Subdomain struct { } func (s *Subdomain) Render() string { - return fmt.Sprintf("[+] Found: %s (%s)", s.Name, s.LastSeen.Format(time.RFC3339)) + return style.Foundf("Found: %s %s", + style.White(s.Name), + style.Dim("("+s.LastSeen.Format(time.RFC3339)+")"), + ) +} + +// RenderAll renders every subdomain, one hit per line, padded with blank lines +// so the block sits apart from the prompt like the other modules' output. +func RenderAll(subs []Subdomain) string { + var b strings.Builder + + b.WriteString("\r\n") + if len(subs) == 0 { + b.WriteString(style.Failure("No subdomains found") + "\r\n") + } + for _, s := range subs { + b.WriteString(s.Render()) + b.WriteString("\r\n") + } + b.WriteString("\r\n") + + return b.String() } diff --git a/pkg/subdomains/subdomain_test.go b/pkg/subdomains/subdomain_test.go index d621b15..bee2996 100644 --- a/pkg/subdomains/subdomain_test.go +++ b/pkg/subdomains/subdomain_test.go @@ -1,6 +1,7 @@ package subdomains import ( + "strings" "testing" "time" ) @@ -15,3 +16,29 @@ func TestSubdomainRender(t *testing.T) { t.Errorf("Render() = %q, want %q", got, want) } } + +func TestRenderAllOneHitPerLine(t *testing.T) { + seen := time.Date(2022, 12, 1, 15, 4, 5, 0, time.UTC) + out := RenderAll([]Subdomain{ + {Name: "a.example.com", LastSeen: seen}, + {Name: "b.example.com", LastSeen: seen}, + }) + + lines := strings.Split(strings.TrimSpace(out), "\r\n") + if len(lines) != 2 { + t.Fatalf("RenderAll() produced %d lines, want 2\ngot:\n%s", len(lines), out) + } + for i, want := range []string{"a.example.com", "b.example.com"} { + if !strings.HasPrefix(lines[i], "[+] Found: "+want) { + t.Errorf("line %d = %q, want a [+] hit for %q", i, lines[i], want) + } + } +} + +func TestRenderAllEmpty(t *testing.T) { + out := RenderAll(nil) + + if !strings.HasPrefix(strings.TrimSpace(out), "[-] No subdomains found") { + t.Errorf("RenderAll() with no results = %q, want a [-] line", out) + } +} From d31d06bd6cc21906ad92a55d4b2502194885f5b4 Mon Sep 17 00:00:00 2001 From: IdanK Date: Sat, 1 Aug 2026 01:19:22 +0300 Subject: [PATCH 2/2] Update ci --- .github/workflows/pr.yml | 35 ++++++++++++-- cmd/main.go | 79 ++++++++++++++------------------ internal/cmd/clear_test.go | 8 ---- internal/cmd/command.go | 6 +-- internal/cmd/info.go | 13 ++---- internal/cmd/info_test.go | 10 ---- internal/cmd/target.go | 3 +- internal/logger/log.go | 76 ------------------------------ internal/logger/log_test.go | 57 ----------------------- internal/module/dns.go | 8 ++-- internal/module/graph.go | 4 +- internal/module/http.go | 5 +- internal/module/manager.go | 11 ++--- internal/module/runner.go | 21 +++++++-- internal/module/subdomains.go | 30 +++++------- internal/style/style.go | 59 +++++++++++++++++++++++- internal/style/style_test.go | 51 +++++++++++++++++++-- internal/versions/version.go | 3 +- internal/web/validation_test.go | 10 ---- pkg/records/dns.go | 49 ++++++++++++-------- pkg/records/dns_test.go | 30 +++++++----- pkg/records/http.go | 67 +++++++++++++++++++++------ pkg/records/http_test.go | 9 ++++ pkg/subdomains/subdomain.go | 27 ++++++++++- pkg/subdomains/subdomain_test.go | 27 +++++++++++ 25 files changed, 382 insertions(+), 316 deletions(-) delete mode 100644 internal/logger/log.go delete mode 100644 internal/logger/log_test.go diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1f5b138..67e2d2d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,9 +1,38 @@ name: Request review + on: pull_request_target: types: [opened, reopened, ready_for_review] + +permissions: + pull-requests: write + +concurrency: + group: request-review-${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + REVIEWER: 'IdanKoblik' + jobs: request-review: - uses: GoScouter/.github/.github/workflows/request-review.yml@main - permissions: - pull-requests: write + name: Request review + runs-on: ubuntu-latest + # Drafts get a reviewer once they're marked ready, not before. + if: github.event.pull_request.draft == false + steps: + - name: Request review + env: + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + # GitHub rejects a review request naming the pull request's own + # author, so IdanKoblik's own pull requests are left alone. + if [ "$PR_AUTHOR" = "$REVIEWER" ]; then + echo "::notice::$PR_AUTHOR authored this pull request — no review requested." + exit 0 + fi + + gh pr edit "$PR_URL" --add-reviewer "$REVIEWER" + echo "::notice::Requested review from $REVIEWER." diff --git a/cmd/main.go b/cmd/main.go index cd9fadd..933c0c3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,7 +7,6 @@ import ( "flag" "fmt" "io" - "log/slog" "os" "os/signal" "strings" @@ -16,7 +15,6 @@ import ( "goscouter/internal" "goscouter/internal/cmd" - "goscouter/internal/logger" "goscouter/internal/module" "goscouter/internal/style" "goscouter/internal/terminal" @@ -37,70 +35,61 @@ func main() { flag.Parse() if *version { - fmt.Println("Version:", VERSION) - os.Exit(0) + fmt.Println(versionString()) + return } if *targetSite == "" { - fmt.Println("Usage: gs --target ") + fmt.Fprintln(os.Stderr, "Usage: gs --target ") os.Exit(1) } + if err := run(*targetSite); err != nil { + fmt.Fprintf(os.Stderr, "%s\r\n", style.Error(err.Error())) + os.Exit(1) + } +} + +func run(target string) error { printBanner() - err := logger.SetupLogger(logger.LoggerConfig{ - Console: false, - Level: slog.LevelInfo, - }) - if err != nil { - panic(err) + if err := versions.SuggestUpdate(VERSION); err != nil { + return fmt.Errorf("update check: %w", err) } - if err = versions.SuggestUpdate(VERSION); err != nil { - logger.Log.Warn("Update check failed", "error", err) - fmt.Printf("%s\n\n", style.Error("Update: "+err.Error())) - return - } + fmt.Printf("%s %s\n\n", style.Gray("Target:"), style.Bold(target)) - fmt.Printf("%s %s\n\n", style.Gray("Target:"), style.Bold(*targetSite)) - logger.Log.Info("Entering terminal raw mode") state, err := terminal.NewShellState() if err != nil { - panic(err) + return err } + defer state.Restore() - logger.Log.Info("Loading modules") moduleManager := module.NewManager() - - if err = moduleManager.LoadExternals(context.Background()); err != nil { - panic(err) + if err := moduleManager.LoadExternals(context.Background()); err != nil { + return err } - logger.Log.Info("Building module dependency graph") - graph, err := moduleManager.Build() - if err != nil { - logger.Log.Warn("Module dependency graph is incomplete", "error", err) - fmt.Printf("%s\n", style.Error("Modules: "+err.Error())) - } - if order, err := graph.Order(); err == nil { - logger.Log.Info("Module run order resolved", "order", strings.Join(order, " -> ")) + // An incomplete graph only disables the modules that depend on what is + // missing, so report it and keep going. + if _, err := moduleManager.Build(); err != nil { + fmt.Printf("%s\r\n", style.Alertf("modules: %v", err)) } runner, err := module.CreateRunner() if err != nil { - panic(err) + return err } go func() { if err := runner.Start(context.Background()); err != nil { - panic(err) + fmt.Fprintf(os.Stderr, "%s\r\n", style.Errorf("runner: %v", err)) } }() - logger.Log.Info("Starting command manager") - commandManager, err := cmd.NewManager(*targetSite, moduleManager) + commandManager, err := cmd.NewManager(target, moduleManager) if err != nil { - panic(err) + return err } sigChan := make(chan os.Signal, 1) @@ -155,8 +144,15 @@ func main() { runner.CleanupState() } - logger.Log.Info("Exiting terminal raw mode, restoring old state") - defer state.Restore() + return nil +} + +// versionString falls back to "dev" for builds made without the release ldflags. +func versionString() string { + if VERSION == "" { + return "dev" + } + return VERSION } func printBanner() { @@ -165,12 +161,7 @@ func printBanner() { buildTime = "unknown" } internal.BuildTime = buildTime - - version := VERSION - if version == "" { - version = "dev" - } - internal.Version = version + internal.Version = versionString() utils.PrintBanner(internal.Version, internal.BuildTime) } diff --git a/internal/cmd/clear_test.go b/internal/cmd/clear_test.go index 493e31f..3b70794 100644 --- a/internal/cmd/clear_test.go +++ b/internal/cmd/clear_test.go @@ -2,19 +2,11 @@ package cmd import ( "io" - "log/slog" "os" "strings" "testing" - - "goscouter/internal/logger" ) -func TestMain(m *testing.M) { - logger.Log = slog.New(slog.NewTextHandler(io.Discard, nil)) - os.Exit(m.Run()) -} - func captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/internal/cmd/command.go b/internal/cmd/command.go index b0edab6..ecced21 100644 --- a/internal/cmd/command.go +++ b/internal/cmd/command.go @@ -2,13 +2,12 @@ package cmd import ( "fmt" - "goscouter/internal/module" "maps" "regexp" "slices" "strings" - "goscouter/internal/logger" + "goscouter/internal/module" ) type Command interface { @@ -32,15 +31,12 @@ func NewManager(target string, manager *module.Manager) (*CommandManager, error) Target: target, } - logger.Log.Info("Loading built-in commands") cm.addCommand(&InfoCommand{}) cm.addCommand(&ExitCommand{}) cm.addCommand(&ClearCommand{}) cm.addCommand(&HelpCommand{Manager: cm}) cm.addCommand(&TargetCommand{Manager: cm}) - logger.Log.Info("Loaded built-in commands.") - if manager == nil { return cm, nil } diff --git a/internal/cmd/info.go b/internal/cmd/info.go index f68ae79..417d17f 100644 --- a/internal/cmd/info.go +++ b/internal/cmd/info.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "regexp" "runtime" "strings" @@ -47,12 +46,6 @@ var logo = []string{ style.Yellow(" .-==- ") + style.Cyan(".--=======--- ") + style.Yellow(".==-"), } -var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m") - -func visibleWidth(s string) int { - return len([]rune(ansiRE.ReplaceAllString(s, ""))) -} - func field(key, value string) string { return style.Bold(style.White(key)) + style.Gray(" : ") + value } @@ -65,7 +58,7 @@ func colorSwatch() string { func infoLines() []string { title := style.Bold(style.Cyan("GoScouter")) - rule := style.Gray(strings.Repeat("─", visibleWidth(title)+9)) + rule := style.Gray(strings.Repeat("─", style.Width(title)+9)) return []string{ title, @@ -94,7 +87,7 @@ func (cmd *InfoCommand) Exec(args []string) error { logoWidth := 0 for _, line := range logo { - if w := visibleWidth(line); w > logoWidth { + if w := style.Width(line); w > logoWidth { logoWidth = w } } @@ -113,7 +106,7 @@ func (cmd *InfoCommand) Exec(args []string) error { logoLine = style.BoldAll(logo[i]) } - padding := logoWidth + gap - visibleWidth(logoLine) + padding := logoWidth + gap - style.Width(logoLine) if padding < 0 { padding = 0 } diff --git a/internal/cmd/info_test.go b/internal/cmd/info_test.go index cb3374d..8bc485f 100644 --- a/internal/cmd/info_test.go +++ b/internal/cmd/info_test.go @@ -45,13 +45,3 @@ func TestInfoCommandExec(t *testing.T) { } } } - -func TestVisibleWidthStripsANSI(t *testing.T) { - styled := "\x1b[31mabc\x1b[0m" - if got := visibleWidth(styled); got != 3 { - t.Fatalf("expected visible width 3, got %d", got) - } - if got := visibleWidth("héllo"); got != 5 { - t.Fatalf("expected visible width 5 for multibyte string, got %d", got) - } -} diff --git a/internal/cmd/target.go b/internal/cmd/target.go index 833cdfb..abe31db 100644 --- a/internal/cmd/target.go +++ b/internal/cmd/target.go @@ -2,7 +2,7 @@ package cmd import ( "fmt" - "goscouter/internal/logger" + "goscouter/internal/style" ) @@ -35,7 +35,6 @@ func (cmd *TargetCommand) Exec(args []string) error { cmd.Manager.SetTarget(target) - logger.Log.Info(fmt.Sprintf("Target set to %q", target)) fmt.Printf("%s\r\n", style.Successf("Target set to %s", style.Bold(target))) return nil } diff --git a/internal/logger/log.go b/internal/logger/log.go deleted file mode 100644 index f237611..0000000 --- a/internal/logger/log.go +++ /dev/null @@ -1,76 +0,0 @@ -package logger - -import ( - "io" - "log/slog" - "os" - "path/filepath" -) - -type LoggerConfig struct { - Console bool - Level slog.Level -} - -var Log *slog.Logger - -// logFile is the file handle backing Log, retained so it can be closed. -var logFile *os.File - -// Close releases the log file handle. Safe to call when the logger was never -// set up. On Windows an open handle prevents the file from being removed, so -// tests (and any short-lived setup) must call this before cleanup. -func Close() error { - if logFile == nil { - return nil - } - err := logFile.Close() - logFile = nil - return err -} - -func LogPath() (string, error) { - dir, err := os.UserHomeDir() - if err != nil { - return "", err - } - - dir = filepath.Join(dir, "goscouter") - if err := os.MkdirAll(dir, 0755); err != nil { - return "", err - } - - return filepath.Join(dir, "goscouter.log"), nil -} - -func SetupLogger(cfg LoggerConfig) error { - logPath, err := LogPath() - if err != nil { - return err - } - - file, err := os.OpenFile( - logPath, - os.O_CREATE|os.O_WRONLY|os.O_APPEND, - 0644, - ) - if err != nil { - return err - } - - var writer io.Writer = file - if cfg.Console { - writer = io.MultiWriter(os.Stdout, file) - } - - opts := &slog.HandlerOptions{ - Level: cfg.Level, - AddSource: true, - } - - handler := slog.NewTextHandler(writer, opts) - Log = slog.New(handler) - logFile = file - - return nil -} diff --git a/internal/logger/log_test.go b/internal/logger/log_test.go deleted file mode 100644 index c58c78e..0000000 --- a/internal/logger/log_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package logger - -import ( - "log/slog" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLogPath(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) // os.UserHomeDir uses USERPROFILE on Windows - - got, err := LogPath() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if filepath.Base(got) != "goscouter.log" { - t.Fatalf("expected file goscouter.log, got %q", got) - } - if !strings.HasSuffix(filepath.Dir(got), "goscouter") { - t.Fatalf("expected path under goscouter dir, got %q", got) - } - - if info, err := os.Stat(filepath.Dir(got)); err != nil || !info.IsDir() { - t.Fatalf("expected log directory to exist, err=%v", err) - } -} - -func TestSetupLogger(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) // os.UserHomeDir uses USERPROFILE on Windows - - if err := SetupLogger(LoggerConfig{Console: false, Level: slog.LevelInfo}); err != nil { - t.Fatalf("unexpected error: %v", err) - } - // Windows cannot remove a file while a handle is open; release it before - // t.TempDir's cleanup runs so RemoveAll succeeds. - defer Close() - if Log == nil { - t.Fatal("expected Log to be initialized") - } - - Log.Info("hello from test", "key", "value") - - data, err := os.ReadFile(filepath.Join(home, "goscouter", "goscouter.log")) - if err != nil { - t.Fatalf("reading log file: %v", err) - } - if !strings.Contains(string(data), "hello from test") { - t.Fatalf("expected log message in file, got %q", string(data)) - } -} diff --git a/internal/module/dns.go b/internal/module/dns.go index 804c55a..8734d9c 100644 --- a/internal/module/dns.go +++ b/internal/module/dns.go @@ -3,10 +3,11 @@ package module import ( "encoding/json" "fmt" + "net/url" + "goscouter/internal/dns" + "goscouter/internal/style" "goscouter/pkg/records" - "log" - "net/url" "github.com/GoScouter/sdk" ) @@ -44,8 +45,7 @@ func (m *DnsModule) Scout(target string, _ []string) (json.RawMessage, error) { func (m *DnsModule) Render(raw json.RawMessage) string { var dns records.DNSRecords if err := json.Unmarshal(raw, &dns); err != nil { - log.Print(err.Error()) - return "" + return style.Failuref("dns: unreadable results: %v\r\n", err) } return dns.Render() diff --git a/internal/module/graph.go b/internal/module/graph.go index bcada16..25c85a5 100644 --- a/internal/module/graph.go +++ b/internal/module/graph.go @@ -27,7 +27,7 @@ func BuildGraph(infos []sdk.ModuleInfo) (*Graph, error) { g := &Graph{sorter: topsort.NewGraph(), known: make(map[string]bool, len(infos))} for _, info := range infos { - if info.Author == internalAuthor && info.Name == "subdomains" { + if info.Author == internalAuthor && info.Name == SdModuleInfo.Name { continue } g.known[Key(Namespace(info))] = true @@ -36,7 +36,7 @@ func BuildGraph(infos []sdk.ModuleInfo) (*Graph, error) { var problems []error for _, info := range infos { - if info.Author == internalAuthor && info.Name == "subdomains" { + if info.Author == internalAuthor && info.Name == SdModuleInfo.Name { continue } diff --git a/internal/module/http.go b/internal/module/http.go index 126a8c5..036a693 100644 --- a/internal/module/http.go +++ b/internal/module/http.go @@ -4,10 +4,10 @@ import ( "encoding/json" "flag" "io" - "log" "net/url" "strings" + "goscouter/internal/style" "goscouter/internal/web" "goscouter/pkg/records" @@ -74,8 +74,7 @@ func (m *HttpModule) Scout(target string, args []string) (json.RawMessage, error func (m *HttpModule) Render(raw json.RawMessage) string { var r records.HTTPRecords if err := json.Unmarshal(raw, &r); err != nil { - log.Print(err.Error()) - return "" + return style.Failuref("http: unreadable results: %v\r\n", err) } return r.Render() diff --git a/internal/module/manager.go b/internal/module/manager.go index 777136c..98c1358 100644 --- a/internal/module/manager.go +++ b/internal/module/manager.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "goscouter/internal/utils" "maps" "os" "path/filepath" @@ -13,7 +12,8 @@ import ( "sync" "time" - "goscouter/internal/logger" + "goscouter/internal/style" + "goscouter/internal/utils" "github.com/GoScouter/sdk" ) @@ -248,9 +248,8 @@ func executable(entry os.DirEntry) bool { return info.Mode().Perm()&0o111 != 0 } +// warn reports a problem the user can act on — a module that could not be +// loaded, or one that would not shut down — without aborting the session. func warn(msg string) { - if logger.Log == nil { - return - } - logger.Log.Warn(msg) + fmt.Fprintf(os.Stderr, "%s\r\n", style.Alert(msg)) } diff --git a/internal/module/runner.go b/internal/module/runner.go index 81d8df4..137594d 100644 --- a/internal/module/runner.go +++ b/internal/module/runner.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log" "net" "os" "strings" @@ -93,7 +92,7 @@ func (r *Runner) Start(ctx context.Context) error { if ctx.Err() != nil { return nil } - log.Printf("runner: accept: %v", err) + warn(fmt.Sprintf("runner: accept: %v", err)) continue } @@ -111,13 +110,13 @@ func (r *Runner) handleClient(conn net.Conn) { var req sdk.Request if err := dec.Decode(&req); err != nil { if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { - log.Printf("runner: decode request: %v", err) + warn(fmt.Sprintf("runner: decode request: %v", err)) } return } if err := r.handle(enc, &req); err != nil { - log.Printf("runner: %q request: %v", req.Type, err) + warn(fmt.Sprintf("runner: %q request: %v", req.Type, err)) } } } @@ -214,6 +213,20 @@ func (r *Reporter) Report(namespace sdk.ModuleNamespace, data json.RawMessage) e } func RunInOrder(info sdk.ModuleInfo, target string, args []string, manager *Manager, reporter *Reporter) ([]json.RawMessage, []string, error) { + if Namespace(info) == Namespace(SdModuleInfo) { + sd := manager.GetInternal(Key(Namespace(SdModuleInfo))) + data, err := sd.Scout(target, args) + if err != nil { + return nil, nil, err + } + + if err := reporter.Report(Namespace(SdModuleInfo), data); err != nil { + return nil, nil, err + } + + return []json.RawMessage{data}, []string{sd.Render(data)}, nil + } + plan, err := manager.Graph.Plan(Key(Namespace(info))) if err != nil { return nil, nil, err diff --git a/internal/module/subdomains.go b/internal/module/subdomains.go index 4e50bac..a04ba5d 100644 --- a/internal/module/subdomains.go +++ b/internal/module/subdomains.go @@ -3,11 +3,9 @@ package module import ( "context" "encoding/json" - "fmt" - "log" - "strings" "goscouter/internal/net/subdomain" + "goscouter/internal/style" pkg "goscouter/pkg/subdomains" "github.com/GoScouter/sdk" @@ -15,18 +13,18 @@ import ( type SubdomainsModule struct{} +var SdModuleInfo = sdk.ModuleInfo{ + Name: "subdomains", + Author: internalAuthor, + Description: "Gather the subdomains of the target domain.", + Dependencies: make([]sdk.ModuleNamespace, 0), +} + func (m *SubdomainsModule) Info() sdk.ModuleInfo { - return sdk.ModuleInfo{ - Name: "subdomains", - Author: internalAuthor, - Description: "Gather the subdomains of the target domain.", - Dependencies: make([]sdk.ModuleNamespace, 0), - } + return SdModuleInfo } func (m *SubdomainsModule) Scout(target string, _ []string) (json.RawMessage, error) { - fmt.Printf("» subdomains: enumerating %s\r\n", target) - ctx, cancel := context.WithTimeout(context.Background(), subdomain.TIMEOUT) defer cancel() @@ -50,14 +48,8 @@ type subdomainResults struct { func (m *SubdomainsModule) Render(raw json.RawMessage) string { var results subdomainResults if err := json.Unmarshal(raw, &results); err != nil { - log.Print(err.Error()) - return "" + return style.Failuref("subdomains: unreadable results: %v\r\n", err) } - var b strings.Builder - for _, s := range results.Subs { - b.WriteString(s.Render()) - b.WriteString("\r\n") - } - return b.String() + return pkg.RenderAll(results.Subs) } diff --git a/internal/style/style.go b/internal/style/style.go index 1812fea..58fd620 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -3,6 +3,7 @@ package style import ( "fmt" "os" + "regexp" "strings" "golang.org/x/term" @@ -39,7 +40,7 @@ func wrap(code, s string) string { return code + s + reset } -func Bold(s string) string { return wrap(codeBold, s) } +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 @@ -87,3 +88,59 @@ func Info(msg string) string { func Infof(format string, a ...any) string { return Info(fmt.Sprintf(format, a...)) } + +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 +} + +func Foundf(format string, a ...any) string { + return Found(fmt.Sprintf(format, a...)) +} + +func Failure(msg string) string { + return Red("[-] ") + msg +} + +func Failuref(format string, a ...any) string { + return Failure(fmt.Sprintf(format, a...)) +} + +func Alert(msg string) string { + return Yellow("[!] ") + 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 + + b.WriteString("\r\n") + b.WriteString(Bold(Cyan("["+title+"]")) + "\r\n") + for _, line := range body { + b.WriteString(line + "\r\n") + } + b.WriteString("\r\n") + + 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/internal/style/style_test.go b/internal/style/style_test.go index 9c8be9b..1aa49dd 100644 --- a/internal/style/style_test.go +++ b/internal/style/style_test.go @@ -37,9 +37,12 @@ func TestEnabledWraps(t *testing.T) { func TestSemanticPrefixes(t *testing.T) { withEnabled(t, false, func() { cases := map[string]string{ - "✗ ": Error("x"), - "✓ ": Success("x"), - "» ": Info("x"), + "✗ ": Error("x"), + "✓ ": Success("x"), + "» ": Info("x"), + "[+] ": Found("x"), + "[-] ": Failure("x"), + "[!] ": Alert("x"), } for prefix, out := range cases { if !strings.HasPrefix(out, prefix) { @@ -48,3 +51,45 @@ func TestSemanticPrefixes(t *testing.T) { } }) } + +func TestWidthStripsANSI(t *testing.T) { + if got := Width("\x1b[31mabc\x1b[0m"); got != 3 { + t.Fatalf("Width of styled %q = %d, want 3", "abc", got) + } + if got := Width("héllo"); got != 5 { + t.Fatalf("Width of multibyte string = %d, want 5", got) + } +} + +func TestSectionBracketsTheTitle(t *testing.T) { + withEnabled(t, false, func() { + body := " A 1.2.3.4" + out := Section("DNS", body) + + lines := strings.Split(strings.TrimSpace(out), "\r\n") + if lines[0] != "[DNS]" { + t.Fatalf("first line = %q, want a bracketed title", lines[0]) + } + if lines[1] != body { + t.Fatalf("body line = %q, want it rendered verbatim", lines[1]) + } + }) +} + +func TestSectionPadsWithBlankLines(t *testing.T) { + withEnabled(t, false, func() { + out := Section("DNS") + + if !strings.HasPrefix(out, "\r\n") || !strings.HasSuffix(out, "\r\n\r\n") { + t.Fatalf("Section = %q, want it padded away from the surrounding output", out) + } + }) +} + +func TestFieldPadsLabel(t *testing.T) { + withEnabled(t, false, func() { + if got := Field("A", 6, "1.2.3.4"); got != " A 1.2.3.4" { + t.Fatalf("Field = %q, want the label padded into a column", got) + } + }) +} diff --git a/internal/versions/version.go b/internal/versions/version.go index e8069d0..067a7ac 100644 --- a/internal/versions/version.go +++ b/internal/versions/version.go @@ -14,7 +14,6 @@ import ( "strings" "time" - "goscouter/internal/logger" "goscouter/internal/style" "github.com/google/go-github/github" @@ -213,8 +212,8 @@ func install(staged, exe string) error { } func SuggestUpdate(current string) error { + // Unversioned builds have nothing to compare against. if current == "" || current == "dev" { - logger.Log.Info("Skipping update check for an unversioned build") return nil } diff --git a/internal/web/validation_test.go b/internal/web/validation_test.go index 0ed3988..4d36e75 100644 --- a/internal/web/validation_test.go +++ b/internal/web/validation_test.go @@ -1,21 +1,11 @@ package web import ( - "io" - "log/slog" "net/http" "net/http/httptest" - "os" "testing" - - "goscouter/internal/logger" ) -func TestMain(m *testing.M) { - logger.Log = slog.New(slog.NewTextHandler(io.Discard, nil)) - os.Exit(m.Run()) -} - func TestCheckSiteStatus(t *testing.T) { tests := []struct { name string diff --git a/pkg/records/dns.go b/pkg/records/dns.go index 6ad8f7c..9647cd7 100644 --- a/pkg/records/dns.go +++ b/pkg/records/dns.go @@ -1,8 +1,7 @@ package records import ( - "fmt" - "strings" + "goscouter/internal/style" ) type DNSRecords struct { @@ -15,30 +14,44 @@ type DNSRecords struct { TXT []string `json:"txt"` } +const dnsLabelWidth = 6 + func (r *DNSRecords) Render() string { - var b strings.Builder + sets := []struct { + label string + values []string + }{ + {"A", r.A}, + {"AAAA", r.AAAA}, + {"CNAME", nonEmpty(r.CNAME)}, + {"MX", r.MX}, + {"NS", r.NS}, + {"TXT", r.TXT}, + } - b.WriteString("\r\n[DNS]\r\n") - writeRecordSet(&b, "A", r.A) - writeRecordSet(&b, "AAAA", r.AAAA) - if r.CNAME != "" { - writeRecordSet(&b, "CNAME", []string{r.CNAME}) + lines := make([]string, 0, len(sets)) + for _, set := range sets { + lines = append(lines, recordSet(set.label, set.values)...) } - writeRecordSet(&b, "MX", r.MX) - writeRecordSet(&b, "NS", r.NS) - writeRecordSet(&b, "TXT", r.TXT) + if len(lines) == 0 { + lines = append(lines, style.Failure("no records found")) + } - b.WriteString("\r\n") - return b.String() + return style.Section("DNS", lines...) } -func writeRecordSet(b *strings.Builder, label string, values []string) { - if len(values) == 0 { - return +func recordSet(label string, values []string) []string { + lines := make([]string, 0, len(values)) + for _, v := range values { + lines = append(lines, style.Field(label, dnsLabelWidth, style.White(v))) } + return lines +} - for _, v := range values { - fmt.Fprintf(b, " %-6s %s\r\n", label, v) +func nonEmpty(v string) []string { + if v == "" { + return nil } + return []string{v} } diff --git a/pkg/records/dns_test.go b/pkg/records/dns_test.go index e46c580..311898e 100644 --- a/pkg/records/dns_test.go +++ b/pkg/records/dns_test.go @@ -54,7 +54,10 @@ func TestDNSRecordsRenderEmpty(t *testing.T) { out := r.Render() if !strings.Contains(out, "[DNS]") { - t.Errorf("Render() should still contain the [DNS] header, got:\n%s", out) + t.Errorf("Render() should still contain the [DNS] heading, got:\n%s", out) + } + if !strings.Contains(out, "[-] no records found") { + t.Errorf("Render() should say so when there is nothing to show, got:\n%s", out) } for _, label := range []string{" A ", " AAAA ", " CNAME ", " MX ", " NS ", " TXT "} { if strings.Contains(out, label) { @@ -63,19 +66,22 @@ func TestDNSRecordsRenderEmpty(t *testing.T) { } } -func TestWriteRecordSetSkipsEmpty(t *testing.T) { - var b strings.Builder - writeRecordSet(&b, "A", nil) - if b.Len() != 0 { - t.Errorf("writeRecordSet with empty values wrote %q, want nothing", b.String()) +func TestRecordSetSkipsEmpty(t *testing.T) { + if lines := recordSet("A", nil); len(lines) != 0 { + t.Errorf("recordSet with empty values returned %q, want nothing", lines) } } -func TestWriteRecordSetFormat(t *testing.T) { - var b strings.Builder - writeRecordSet(&b, "A", []string{"1.2.3.4"}) - want := " A 1.2.3.4\r\n" - if b.String() != want { - t.Errorf("writeRecordSet = %q, want %q", b.String(), want) +func TestRecordSetFormat(t *testing.T) { + lines := recordSet("A", []string{"1.2.3.4", "5.6.7.8"}) + + want := []string{" A 1.2.3.4", " A 5.6.7.8"} + if len(lines) != len(want) { + t.Fatalf("recordSet returned %d lines, want %d", len(lines), len(want)) + } + for i := range want { + if lines[i] != want[i] { + t.Errorf("recordSet line %d = %q, want %q", i, lines[i], want[i]) + } } } diff --git a/pkg/records/http.go b/pkg/records/http.go index 49380d5..5314326 100644 --- a/pkg/records/http.go +++ b/pkg/records/http.go @@ -5,6 +5,8 @@ import ( "net/http" "sort" "strings" + + "goscouter/internal/style" ) type HTTPRecords struct { @@ -17,31 +19,66 @@ type HTTPRecords struct { Headers http.Header `json:"headers"` } +const httpLabelWidth = 9 + func (r *HTTPRecords) Render() string { - var b strings.Builder + lines := []string{ + field("Status", statusColor(r.StatusCode, r.Status)), + field("Protocol", style.White(r.Proto)), + } - fmt.Fprintf(&b, "\r\n[%s]\r\n", r.Scheme) - fmt.Fprintf(&b, " Status : %s\r\n", r.Status) - fmt.Fprintf(&b, " Protocol : %s\r\n", r.Proto) if r.FinalURL != "" && r.FinalURL != r.RequestURL { - fmt.Fprintf(&b, " Redirect : %s -> %s\r\n", r.RequestURL, r.FinalURL) + redirect := style.White(r.RequestURL) + style.Gray(" -> ") + style.White(r.FinalURL) + lines = append(lines, field("Redirect", redirect)) } - b.WriteString(" Headers :\r\n") - keys := make([]string, 0, len(r.Headers)) - for k := range r.Headers { - keys = append(keys, k) + lines = append(lines, label("Headers")) + lines = append(lines, headerLines(r.Headers)...) + + return style.Section(r.Scheme, lines...) +} + +// field renders one " Label : value" row. +func field(name, value string) string { + return label(name) + " " + value +} + +func label(name string) string { + return " " + style.Gray(fmt.Sprintf("%-*s:", httpLabelWidth, name)) +} + +func headerLines(headers http.Header) []string { + if len(headers) == 0 { + return []string{" " + style.Dim("(none)")} } - sort.Strings(keys) - if len(keys) == 0 { - b.WriteString(" (none)\r\n") + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) for _, k := range keys { - fmt.Fprintf(&b, " %s: %s\r\n", k, strings.Join(r.Headers[k], ", ")) + value := style.White(strings.Join(headers[k], ", ")) + lines = append(lines, " "+style.Gray(k+":")+" "+value) } + return lines +} - b.WriteString("\r\n") - return b.String() +// statusColor tints the status line by response class so a failure stands out +// without having to read the code. +func statusColor(code int, status string) string { + switch { + case code >= 200 && code < 300: + return style.Green(status) + case code >= 300 && code < 400: + return style.Cyan(status) + case code >= 400 && code < 500: + return style.Yellow(status) + case code >= 500: + return style.Red(status) + default: + return style.White(status) + } } diff --git a/pkg/records/http_test.go b/pkg/records/http_test.go index 234d713..d0ba04e 100644 --- a/pkg/records/http_test.go +++ b/pkg/records/http_test.go @@ -107,3 +107,12 @@ func TestHTTPRecordsRenderNoHeaders(t *testing.T) { t.Errorf("Render() should print (none) when there are no headers\ngot:\n%s", r.Render()) } } + +func TestStatusColorByClass(t *testing.T) { + // Styling is off in tests, so this only checks the status survives intact. + for _, code := range []int{0, 200, 301, 404, 500} { + if got := statusColor(code, "status"); got != "status" { + t.Errorf("statusColor(%d) = %q, want the status text unchanged", code, got) + } + } +} diff --git a/pkg/subdomains/subdomain.go b/pkg/subdomains/subdomain.go index 3590544..6d50ea7 100644 --- a/pkg/subdomains/subdomain.go +++ b/pkg/subdomains/subdomain.go @@ -1,8 +1,10 @@ package subdomains import ( - "fmt" + "strings" "time" + + "goscouter/internal/style" ) type Subdomain struct { @@ -11,5 +13,26 @@ type Subdomain struct { } func (s *Subdomain) Render() string { - return fmt.Sprintf("[+] Found: %s (%s)", s.Name, s.LastSeen.Format(time.RFC3339)) + return style.Foundf("Found: %s %s", + style.White(s.Name), + style.Dim("("+s.LastSeen.Format(time.RFC3339)+")"), + ) +} + +// RenderAll renders every subdomain, one hit per line, padded with blank lines +// so the block sits apart from the prompt like the other modules' output. +func RenderAll(subs []Subdomain) string { + var b strings.Builder + + b.WriteString("\r\n") + if len(subs) == 0 { + b.WriteString(style.Failure("No subdomains found") + "\r\n") + } + for _, s := range subs { + b.WriteString(s.Render()) + b.WriteString("\r\n") + } + b.WriteString("\r\n") + + return b.String() } diff --git a/pkg/subdomains/subdomain_test.go b/pkg/subdomains/subdomain_test.go index d621b15..bee2996 100644 --- a/pkg/subdomains/subdomain_test.go +++ b/pkg/subdomains/subdomain_test.go @@ -1,6 +1,7 @@ package subdomains import ( + "strings" "testing" "time" ) @@ -15,3 +16,29 @@ func TestSubdomainRender(t *testing.T) { t.Errorf("Render() = %q, want %q", got, want) } } + +func TestRenderAllOneHitPerLine(t *testing.T) { + seen := time.Date(2022, 12, 1, 15, 4, 5, 0, time.UTC) + out := RenderAll([]Subdomain{ + {Name: "a.example.com", LastSeen: seen}, + {Name: "b.example.com", LastSeen: seen}, + }) + + lines := strings.Split(strings.TrimSpace(out), "\r\n") + if len(lines) != 2 { + t.Fatalf("RenderAll() produced %d lines, want 2\ngot:\n%s", len(lines), out) + } + for i, want := range []string{"a.example.com", "b.example.com"} { + if !strings.HasPrefix(lines[i], "[+] Found: "+want) { + t.Errorf("line %d = %q, want a [+] hit for %q", i, lines[i], want) + } + } +} + +func TestRenderAllEmpty(t *testing.T) { + out := RenderAll(nil) + + if !strings.HasPrefix(strings.TrimSpace(out), "[-] No subdomains found") { + t.Errorf("RenderAll() with no results = %q, want a [-] line", out) + } +}