From 4efa765866e33f2daf94a37275ba59a928be7bf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:36:58 +0000 Subject: [PATCH 1/2] Initial plan From 1386adc31526ff2885419ce1dc3563e143ae0018 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:57:23 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(cli):=20ProjectDiscovery-style=20UX=20?= =?UTF-8?q?=E2=80=94=20banner,=20colored=20logs,=20braille=20spinner,=20st?= =?UTF-8?q?yled=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/root-Manas/macaron/sessions/6dea78af-da78-401a-9db1-1beab80801a7 Co-authored-by: root-Manas <97402139+root-Manas@users.noreply.github.com> --- README.md | 99 +++++++++++++---- cmd/macaron/main.go | 225 +++++++++++++++++++++++++-------------- internal/app/app.go | 63 ++++++++--- internal/cliui/banner.go | 132 +++++++++++++++++++++++ internal/cliui/live.go | 59 ++++++---- 5 files changed, 442 insertions(+), 136 deletions(-) create mode 100644 internal/cliui/banner.go diff --git a/README.md b/README.md index a0eca32..84233b5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ Fast reconnaissance workflow in Go with SQLite-backed persistence and an operator-focused dashboard. +``` + ╔╦╗╔═╗╔═╗╔═╗╦═╗╔═╗╔╗╔ + ║║║╠═╣║ ╠═╣╠╦╝║ ║║║║ + ╩ ╩╩ ╩╚═╝╩ ╩╩╚═╚═╝╝╚╝ + + Fast Recon Workflow v3.0.0 + github.com/root-Manas/macaron + ──────────────────────────────────────── +``` + ## The Model `macaronV2` is designed around one simple loop: @@ -22,27 +32,76 @@ chmod +x install.sh source ~/.bashrc macaron setup -macaron scan example.com --profile balanced +macaron scan example.com -prf balanced macaron status -macaron serve --addr 127.0.0.1:8088 +macaron serve ``` ## Core Commands -```bash -macaron setup -macaron scan -macaron status -macaron results -d -w -macaron serve -macaron export -o results.json +``` +USAGE + macaron scan example.com + macaron status + macaron results -dom example.com -wht live + macaron serve -adr 127.0.0.1:8088 + macaron setup + macaron export -out results.json + +SCAN FLAGS + -scn TARGET Scan one or more targets (repeatable) + -fil FILE Read targets from file + -inp Read targets from stdin + -mod MODE Scan mode: wide|narrow|fast|deep|osint + -stg LIST Stages: subdomains,http,ports,urls,vulns + -prf NAME Profile: passive|balanced|aggressive + -rte N Request rate hint (default: 150) + -thr N Worker threads (default: 30) + +OUTPUT FLAGS + -sts Show recent scan summaries + -res Show scan results + -dom DOMAIN Filter by domain + -wht TYPE Result view: all|subdomains|live|ports|urls|js|vulns + -lim N Output limit (default: 50) + -exp Export results to JSON + -qut Quiet mode (suppress banner and progress) + +API KEYS + -sak k=v Set API key (e.g. -sak securitytrails=KEY) + -shk Show masked API keys + +DASHBOARD + -srv Start browser dashboard + -adr ADDR Bind address (default: 127.0.0.1:8088) + +TOOLS & CONFIG + -stp Show tool installation status + -ins Install missing supported tools (Linux) + -lst List external tool availability + -str DIR Custom storage root (default: ./storage) + -nc Disable color output + -ver Show version ``` ## Profiles -- `passive`: low-noise collection -- `balanced`: default practical workflow -- `aggressive`: high-throughput authorized testing +| Profile | Description | +|-------------|----------------------------------------------------| +| `passive` | Low-noise, low-rate, mostly passive collection | +| `balanced` | Default practical workflow (recommended) | +| `aggressive`| High-throughput for authorized deep testing only | + +## CLI UX + +macaron follows the same UX patterns as ProjectDiscovery tools (nuclei, httpx, subfinder): + +- **Colored log levels**: `[INF]`, `[WRN]`, `[ERR]`, `[OK]` with distinct colors +- **Live progress**: Braille-spinner with stage and elapsed time during scans +- **Colored tables**: Vulns highlighted in red, live hosts in green +- **Compact flags**: Short (`-scn`, `-mod`, `-prf`) with full-word aliases also accepted +- **NO_COLOR support**: Respects the `NO_COLOR` environment variable +- **Quiet mode**: `-qut` suppresses banner and progress for scripted use ## Storage @@ -61,26 +120,28 @@ storage/ ```bash macaron setup -macaron --install-tools -macaron --set-api securitytrails=YOUR_KEY -macaron --show-api +macaron -ins +macaron -sak securitytrails=YOUR_KEY +macaron -shk ``` ## Stage Control ```bash -macaron scan example.com --stages subdomains,http,urls +macaron scan example.com -stg subdomains,http,urls ``` -Available stages: `subdomains,http,ports,urls,vulns` +Available stages: `subdomains`, `http`, `ports`, `urls`, `vulns` ## Dashboard ```bash -macaron serve --addr 127.0.0.1:8088 +macaron serve +# or with custom address: +macaron serve -adr 127.0.0.1:8088 ``` -Open `http://127.0.0.1:8088`. +Open `http://127.0.0.1:8088` — includes scan list with mode filters, health badges, URL yield trend, and geo map. ## Release diff --git a/cmd/macaron/main.go b/cmd/macaron/main.go index 26419d2..8c4f06b 100644 --- a/cmd/macaron/main.go +++ b/cmd/macaron/main.go @@ -53,6 +53,7 @@ func run() int { limit int output string quiet bool + noColor bool showVersion bool serveAddr string storagePath string @@ -86,7 +87,8 @@ func run() int { pflag.IntVar(&threads, "thr", 30, "Worker threads") pflag.IntVar(&limit, "lim", 50, "Output limit") pflag.StringVar(&output, "out", "", "Output file") - pflag.BoolVar(&quiet, "qut", false, "Quiet output") + pflag.BoolVar(&quiet, "qut", false, "Quiet output (no banner, no progress)") + pflag.BoolVar(&noColor, "nc", false, "Disable color output") pflag.BoolVar(&showVersion, "ver", false, "Show version") pflag.StringVar(&serveAddr, "adr", "127.0.0.1:8088", "Dashboard bind address") pflag.StringVar(&storagePath, "str", "", "Storage root directory (default: ./storage)") @@ -99,66 +101,74 @@ func run() int { pflag.BoolVar(&guide, "gud", false, "Show first-principles workflow guide") pflag.Parse() + if noColor { + os.Setenv("NO_COLOR", "1") + } + if showVersion { + cliui.PrintBanner(version, false) fmt.Printf("macaronV2 %s (Go %s, stable)\n", version, runtime.Version()) return 0 } if guide { + cliui.PrintBanner(version, quiet) printGuide() return 0 } home, err := macaronHome(storagePath) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + cliui.Err("storage: %v", err) return 1 } application, err := app.New(home) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + cliui.Err("%v", err) return 1 } config, err := cfg.Load(home) if err != nil { - fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) + cliui.Err("loading config: %v", err) return 1 } if len(setAPI) > 0 { cfg.ApplySetAPI(config, setAPI) if err := cfg.Save(home, config); err != nil { - fmt.Fprintf(os.Stderr, "error saving config: %v\n", err) + cliui.Err("saving config: %v", err) return 1 } - fmt.Printf("Saved API keys to %s\n", filepath.Join(home, "config.yaml")) + cliui.OK("API keys saved → %s", filepath.Join(home, "config.yaml")) return 0 } if showAPI { items := cfg.MaskedKeys(config) if len(items) == 0 { - fmt.Println("No API keys configured") + cliui.Info("No API keys configured") return 0 } - fmt.Println("Configured API keys:") + cliui.Info("Configured API keys:") for _, item := range items { - fmt.Printf(" - %s\n", item) + fmt.Printf(" %s %s\n", cliui.CyanText("•"), item) } return 0 } if setup || installTools { + cliui.PrintBanner(version, quiet) tools := app.SetupCatalog() fmt.Print(app.RenderSetup(tools)) if installTools { + cliui.Info("Installing missing tools…") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() installed, err := app.InstallMissingTools(ctx, tools) if err != nil { - fmt.Fprintf(os.Stderr, "setup error: %v\n", err) + cliui.Err("setup: %v", err) return 1 } if len(installed) == 0 { - fmt.Println("No installable missing tools found.") + cliui.Info("No installable missing tools found.") } else { - fmt.Printf("Installed: %s\n", strings.Join(installed, ", ")) + cliui.OK("Installed: %s", strings.Join(installed, ", ")) } fmt.Print(app.RenderSetup(app.SetupCatalog())) } @@ -170,23 +180,25 @@ func run() int { return 0 } if pipeline { - fmt.Printf("Pipeline (macaronV2 native): %s\n", filepath.Join(home, "pipeline.v2.yaml")) + cliui.Info("Pipeline (macaronV2 native): %s", filepath.Join(home, "pipeline.v2.yaml")) return 0 } if listTools { + cliui.PrintBanner(version, quiet) for _, t := range app.ListTools() { - state := "missing" if t.Installed { - state = "installed" + fmt.Printf(" %s %-14s %s\n", cliui.GreenText("✔"), t.Name, cliui.Muted("installed")) + } else { + fmt.Printf(" %s %-14s %s\n", cliui.YellowText("✘"), t.Name, cliui.Muted("missing")) } - fmt.Printf("%-12s %s\n", t.Name, state) } return 0 } if status { + cliui.PrintBanner(version, quiet) out, err := application.ShowStatus(limit) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + cliui.Err("%v", err) return 1 } fmt.Print(out) @@ -195,7 +207,7 @@ func run() int { if results { out, err := application.ShowResults(domain, scanID, what, limit) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + cliui.Err("%v", err) return 1 } fmt.Print(out) @@ -204,16 +216,18 @@ func run() int { if export { path, err := application.Export(output, domain) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + cliui.Err("%v", err) return 1 } - fmt.Printf("Exported: %s\n", path) + cliui.OK("Exported → %s", path) return 0 } if serve { + cliui.PrintBanner(version, quiet) + cliui.Info("Starting dashboard on http://%s", serveAddr) server := ui.New(application.Store) if err := server.Serve(serveAddr); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + cliui.Err("%v", err) return 1 } return 0 @@ -221,7 +235,7 @@ func run() int { targets, err := app.ParseTargets(scanTargets, filePath, useStdin) if err != nil && !errors.Is(err, os.ErrNotExist) { - fmt.Fprintf(os.Stderr, "error: %v\n", err) + cliui.Err("%v", err) return 1 } if len(targets) == 0 { @@ -236,14 +250,24 @@ func run() int { mode = "narrow" } if rate <= 0 { - fmt.Fprintln(os.Stderr, "error: --rate must be > 0") + cliui.Err("--rte (rate) must be > 0") return 1 } if threads <= 0 { - fmt.Fprintln(os.Stderr, "error: --threads must be > 0") + cliui.Err("--thr (threads) must be > 0") return 1 } + cliui.PrintBanner(version, quiet) + if !quiet { + cliui.Info("Profile: %s | mode: %s | stages: %s | rate: %d | threads: %d", + cliui.Highlight(profile), cliui.Highlight(mode), + cliui.Highlight(stages), rate, threads) + for _, t := range targets { + cliui.Info("Target: %s", cliui.Highlight(t)) + } + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -254,9 +278,6 @@ func run() int { renderer = cliui.NewLiveRenderer(os.Stdout) defer renderer.Close() } - if !quiet { - fmt.Printf("Workflow profile: %s | mode=%s | stages=%s | rate=%d | threads=%d\n", profile, mode, stages, rate, threads) - } res, err := application.Scan(ctx, app.ScanArgs{ Targets: targets, Mode: modeVal, @@ -272,46 +293,80 @@ func run() int { }, }) if err != nil { - fmt.Fprintf(os.Stderr, "scan failed: %v\n", err) + cliui.Err("scan failed: %v", err) return 1 } if !quiet { - fmt.Println("macaronV2 scan summary") + elapsed := time.Since(start).Round(time.Millisecond) + cliui.OK("Completed %d target(s) in %s", len(res), elapsed) + fmt.Println() fmt.Println(app.RenderScanSummary(res)) - fmt.Printf("Completed %d target(s) in %s\n", len(res), time.Since(start).Round(time.Millisecond)) } return 0 } func printHelp() { - fmt.Println(`macaronV2 (Go stable rewrite) - -Usage: - macaron scan example.com - macaron status - macaron results -dom example.com -wht live - macaron serve -adr 127.0.0.1:8088 - macaron setup - -Core flags: - -scn TARGET Scan one or more targets - -fil FILE Read targets from file - -inp Read targets from stdin - -mod MODE wide|narrow|fast|deep|osint - -sts Show scan summaries - -res Show scan details - -exp Export JSON - -lst Show tool availability - -str DIR Use custom storage root (default ./storage) - -stg LIST Choose stages: subdomains,http,ports,urls,vulns - -sak k=v Save API keys to storage config.yaml - -shk Show masked API keys - -stp Show setup screen with tool status - -ins Install missing supported tools (Linux) - -prf NAME passive|balanced|aggressive - -gud Show first-principles workflow guide - -srv Start browser dashboard - -ver Show version`) + c := cliui.CyanText + b := cliui.Highlight + m := cliui.Muted + + cliui.PrintBanner(version, false) + fmt.Printf("%s\n", b("USAGE")) + fmt.Printf(" macaron %s example.com\n", c("scan")) + fmt.Printf(" macaron %s\n", c("status")) + fmt.Printf(" macaron %s -dom example.com -wht live\n", c("results")) + fmt.Printf(" macaron %s -adr 127.0.0.1:8088\n", c("serve")) + fmt.Printf(" macaron %s\n", c("setup")) + fmt.Printf(" macaron %s -out results.json\n\n", c("export")) + + fmt.Printf("%s\n", b("SCAN FLAGS")) + fmt.Printf(" %s TARGET %s\n", c("-scn"), m("Scan one or more targets (repeatable)")) + fmt.Printf(" %s FILE %s\n", c("-fil"), m("Read targets from file")) + fmt.Printf(" %s %s\n", c("-inp"), m("Read targets from stdin")) + fmt.Printf(" %s MODE %s\n", c("-mod"), m("Scan mode: wide|narrow|fast|deep|osint")) + fmt.Printf(" %s LIST %s\n", c("-stg"), m("Stages: subdomains,http,ports,urls,vulns")) + fmt.Printf(" %s NAME %s\n", c("-prf"), m("Profile: passive|balanced|aggressive")) + fmt.Printf(" %s N %s\n", c("-rte"), m("Request rate hint (default: 150)")) + fmt.Printf(" %s N %s\n\n", c("-thr"), m("Worker threads (default: 30)")) + + fmt.Printf("%s\n", b("OUTPUT FLAGS")) + fmt.Printf(" %s %s\n", c("-sts"), m("Show recent scan summaries")) + fmt.Printf(" %s %s\n", c("-res"), m("Show scan results")) + fmt.Printf(" %s DOMAIN %s\n", c("-dom"), m("Filter by domain")) + fmt.Printf(" %s ID %s\n", c("-sid"), m("Fetch specific scan ID")) + fmt.Printf(" %s TYPE %s\n", c("-wht"), m("Result view: all|subdomains|live|ports|urls|js|vulns")) + fmt.Printf(" %s N %s\n", c("-lim"), m("Output limit (default: 50)")) + fmt.Printf(" %s FILE %s\n", c("-out"), m("Output file for export")) + fmt.Printf(" %s %s\n", c("-exp"), m("Export results to JSON")) + fmt.Printf(" %s %s\n\n", c("-qut"), m("Quiet mode (suppress banner and progress)")) + + fmt.Printf("%s\n", b("API KEYS")) + fmt.Printf(" %s k=v %s\n", c("-sak"), m("Set API key (e.g. -sak securitytrails=KEY)")) + fmt.Printf(" %s %s\n\n", c("-shk"), m("Show masked API keys")) + + fmt.Printf("%s\n", b("DASHBOARD")) + fmt.Printf(" %s %s\n", c("-srv"), m("Start browser dashboard")) + fmt.Printf(" %s ADDR %s\n\n", c("-adr"), m("Bind address (default: 127.0.0.1:8088)")) + + fmt.Printf("%s\n", b("TOOLS & CONFIG")) + fmt.Printf(" %s %s\n", c("-stp"), m("Show tool installation status")) + fmt.Printf(" %s %s\n", c("-ins"), m("Install missing supported tools (Linux)")) + fmt.Printf(" %s %s\n", c("-lst"), m("List external tool availability")) + fmt.Printf(" %s DIR %s\n", c("-str"), m("Custom storage root (default: ./storage)")) + fmt.Printf(" %s %s\n", c("-cfg"), m("Show config paths")) + fmt.Printf(" %s %s\n", c("-gud"), m("Show first-principles workflow guide")) + fmt.Printf(" %s %s\n", c("-nc"), m("Disable color output")) + fmt.Printf(" %s %s\n\n", c("-ver"), m("Show version")) + + fmt.Printf("%s\n", b("EXAMPLES")) + fmt.Printf(" %s\n", m("# Passive OSINT scan")) + fmt.Printf(" macaron scan example.com %s passive\n\n", c("-prf")) + fmt.Printf(" %s\n", m("# Aggressive full scan")) + fmt.Printf(" macaron scan example.com %s aggressive %s subdomains,http,ports,urls,vulns\n\n", c("-prf"), c("-stg")) + fmt.Printf(" %s\n", m("# View live hosts from last scan")) + fmt.Printf(" macaron results %s example.com %s live\n\n", c("-dom"), c("-wht")) + fmt.Printf(" %s\n", m("# Use API key for better coverage")) + fmt.Printf(" macaron %s securitytrails=YOUR_KEY\n\n", c("-sak")) } func normalizeLegacyArgs() { @@ -385,6 +440,7 @@ func normalizeCompactFlags() { "limit": "lim", "lim": "lim", "output": "out", "o": "out", "out": "out", "quiet": "qut", "q": "qut", "qut": "qut", + "no-color": "nc", "nc": "nc", "version": "ver", "ver": "ver", "addr": "adr", "adr": "adr", "storage": "str", "str": "str", @@ -456,30 +512,35 @@ func applyProfile(profile string, mode *string, rate *int, threads *int, stages } func printGuide() { - fmt.Println(`macaronV2 guide (first-principles workflow) - -1) Setup once: - macaron setup - macaron -ins - macaron -sak securitytrails=YOUR_KEY - -2) Run intentional scans: - macaron scan target.com -prf passive - macaron scan target.com -prf balanced - macaron scan target.com -prf aggressive -stg subdomains,http,ports,urls,vulns - -3) Inspect and decide: - macaron status - macaron results -dom target.com -wht live - macaron serve - -4) Export/share: - macaron export -out target.json - -Profiles: - passive low-noise, low-rate, mostly passive collection - balanced default practical pipeline - aggressive high concurrency for authorized deep testing only`) + b := cliui.Highlight + c := cliui.CyanText + m := cliui.Muted + g := cliui.GreenText + + fmt.Printf("%s\n\n", b("WORKFLOW GUIDE — first principles")) + + fmt.Printf("%s %s\n", g("1)"), b("Setup once")) + fmt.Printf(" macaron %s\n", c("setup")) + fmt.Printf(" macaron %s\n", c("-ins")) + fmt.Printf(" macaron %s securitytrails=YOUR_KEY\n\n", c("-sak")) + + fmt.Printf("%s %s\n", g("2)"), b("Run intentional scans")) + fmt.Printf(" macaron scan target.com %s passive\n", c("-prf")) + fmt.Printf(" macaron scan target.com %s balanced\n", c("-prf")) + fmt.Printf(" macaron scan target.com %s aggressive %s subdomains,http,ports,urls,vulns\n\n", c("-prf"), c("-stg")) + + fmt.Printf("%s %s\n", g("3)"), b("Inspect and decide")) + fmt.Printf(" macaron %s\n", c("status")) + fmt.Printf(" macaron %s %s target.com %s live\n", c("results"), c("-dom"), c("-wht")) + fmt.Printf(" macaron %s\n\n", c("serve")) + + fmt.Printf("%s %s\n", g("4)"), b("Export / share")) + fmt.Printf(" macaron %s %s target.json\n\n", c("export"), c("-out")) + + fmt.Printf("%s\n", b("PROFILES")) + fmt.Printf(" %s %s\n", c("passive"), m("low-noise, low-rate, mostly passive collection")) + fmt.Printf(" %s %s\n", c("balanced"), m("default practical pipeline")) + fmt.Printf(" %s %s\n\n", c("aggressive"), m("high concurrency for authorized deep testing only")) } func macaronHome(override string) (string, error) { diff --git a/internal/app/app.go b/internal/app/app.go index 642accf..706f470 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -16,6 +16,7 @@ import ( "time" "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" "github.com/root-Manas/macaron/internal/engine" "github.com/root-Manas/macaron/internal/model" "github.com/root-Manas/macaron/internal/store" @@ -89,20 +90,28 @@ func (a *App) ShowStatus(limit int) (string, error) { return "", err } if len(summaries) == 0 { - return "No scans found. Run: macaron -s example.com", nil + return "No scans found. Run: macaron scan example.com\n", nil } b := strings.Builder{} - b.WriteString("macaronV2 status\n") tw := table.NewWriter() + tw.SetStyle(tableStyle()) tw.AppendHeader(table.Row{"ID", "TARGET", "MODE", "LIVE", "URLS", "VULNS", "FINISHED"}) for _, s := range summaries { + vulnCell := strconv.Itoa(s.Stats.Vulns) + if s.Stats.Vulns > 0 { + vulnCell = text.Colors{text.FgRed, text.Bold}.Sprint(vulnCell) + } + liveCell := strconv.Itoa(s.Stats.LiveHosts) + if s.Stats.LiveHosts > 0 { + liveCell = text.Colors{text.FgGreen}.Sprint(liveCell) + } tw.AppendRow(table.Row{ - s.ID, - s.Target, + text.Colors{text.FgCyan}.Sprint(s.ID), + text.Colors{text.Bold}.Sprint(s.Target), s.Mode, - strconv.Itoa(s.Stats.LiveHosts), + liveCell, strconv.Itoa(s.Stats.URLs), - strconv.Itoa(s.Stats.Vulns), + vulnCell, s.FinishedAt.Format(time.RFC3339), }) } @@ -257,17 +266,25 @@ func SetupCatalog() []SetupTool { func RenderSetup(tools []SetupTool) string { tw := table.NewWriter() + tw.SetStyle(tableStyle()) tw.AppendHeader(table.Row{"TOOL", "REQUIRED", "STATUS", "INSTALL"}) for _, t := range tools { - required := "no" + required := text.Colors{text.FgYellow}.Sprint("no") if t.Required { - required = "yes" + required = text.Colors{text.FgCyan, text.Bold}.Sprint("yes") } - status := "missing" + var status string if t.Installed { - status = "installed" + status = text.Colors{text.FgGreen, text.Bold}.Sprint("✔ installed") + } else { + status = text.Colors{text.FgRed}.Sprint("✘ missing") } - tw.AppendRow(table.Row{t.Name, required, status, t.InstallCmd}) + tw.AppendRow(table.Row{ + text.Colors{text.Bold}.Sprint(t.Name), + required, + status, + text.Colors{text.Faint}.Sprint(t.InstallCmd), + }) } b := strings.Builder{} b.WriteString("macaron setup\n") @@ -297,15 +314,24 @@ func InstallMissingTools(ctx context.Context, tools []SetupTool) ([]string, erro func RenderScanSummary(results []model.ScanResult) string { tw := table.NewWriter() + tw.SetStyle(tableStyle()) tw.AppendHeader(table.Row{"TARGET", "MODE", "SUBDOMAINS", "LIVE", "URLS", "VULNS", "DURATION"}) for _, r := range results { + vulnCell := strconv.Itoa(r.Stats.Vulns) + if r.Stats.Vulns > 0 { + vulnCell = text.Colors{text.FgRed, text.Bold}.Sprint(vulnCell) + } + liveCell := strconv.Itoa(r.Stats.LiveHosts) + if r.Stats.LiveHosts > 0 { + liveCell = text.Colors{text.FgGreen}.Sprint(liveCell) + } tw.AppendRow(table.Row{ - r.Target, + text.Colors{text.Bold}.Sprint(r.Target), r.Mode, r.Stats.Subdomains, - r.Stats.LiveHosts, + liveCell, r.Stats.URLs, - r.Stats.Vulns, + vulnCell, fmt.Sprintf("%dms", r.DurationMS), }) } @@ -387,3 +413,12 @@ func ParseStages(raw string) map[string]bool { } return out } + +// tableStyle returns a consistent styled table style for all output tables. +func tableStyle() table.Style { + s := table.StyleRounded + s.Color.Header = text.Colors{text.FgCyan, text.Bold} + s.Color.Border = text.Colors{text.Faint} + s.Color.Separator = text.Colors{text.Faint} + return s +} diff --git a/internal/cliui/banner.go b/internal/cliui/banner.go new file mode 100644 index 0000000..a6f67d1 --- /dev/null +++ b/internal/cliui/banner.go @@ -0,0 +1,132 @@ +package cliui + +import ( + "fmt" + "os" + "strings" +) + +// ANSI color codes. +const ( + cReset = "\033[0m" + cBold = "\033[1m" + cDim = "\033[2m" + cCyan = "\033[36m" + cGreen = "\033[32m" + cYellow = "\033[33m" + cRed = "\033[31m" + cMagenta = "\033[35m" +) + +// colorEnabled returns true unless NO_COLOR is set (https://no-color.org). +func colorEnabled() bool { + return strings.TrimSpace(os.Getenv("NO_COLOR")) == "" +} + +func cp(codes, v string) string { + if !colorEnabled() { + return v + } + return codes + v + cReset +} + +// Info writes a cyan [INF] prefixed line to stderr. +func Info(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + prefix := cp(cCyan+cBold, "[INF]") + fmt.Fprintf(os.Stderr, "%s %s\n", prefix, msg) +} + +// Warn writes a yellow [WRN] prefixed line to stderr. +func Warn(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + prefix := cp(cYellow+cBold, "[WRN]") + fmt.Fprintf(os.Stderr, "%s %s\n", prefix, msg) +} + +// Err writes a red [ERR] prefixed line to stderr. +func Err(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + prefix := cp(cRed+cBold, "[ERR]") + fmt.Fprintf(os.Stderr, "%s %s\n", prefix, msg) +} + +// OK writes a green [OK] prefixed line to stderr. +func OK(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + prefix := cp(cGreen+cBold, "[OK]") + fmt.Fprintf(os.Stderr, "%s %s\n", prefix, msg) +} + +// PrintBanner writes the macaron startup banner to stderr. +// It respects NO_COLOR and is silent when quiet is true. +func PrintBanner(version string, quiet bool) { + if quiet { + return + } + c := colorEnabled() + + teal := func(s string) string { + if !c { + return s + } + return cCyan + cBold + s + cReset + } + dim := func(s string) string { + if !c { + return s + } + return cDim + s + cReset + } + magenta := func(s string) string { + if !c { + return s + } + return cMagenta + cBold + s + cReset + } + + // Box-drawing ASCII art for "MACARON" — safe in Go raw string literals. + art := []string{ + `╔╦╗╔═╗╔═╗╔═╗╦═╗╔═╗╔╗╔`, + `║║║╠═╣║ ╠═╣╠╦╝║ ║║║║`, + `╩ ╩╩ ╩╚═╝╩ ╩╩╚═╚═╝╝╚╝`, + } + + fmt.Fprintln(os.Stderr) + for _, line := range art { + fmt.Fprintf(os.Stderr, " %s\n", teal(line)) + } + fmt.Fprintf(os.Stderr, "\n %s %s\n", magenta("Fast Recon Workflow"), dim("v"+version)) + fmt.Fprintf(os.Stderr, " %s\n", dim("github.com/root-Manas/macaron")) + fmt.Fprintf(os.Stderr, " %s\n\n", dim(strings.Repeat("─", 40))) +} + +// Highlight wraps v in bold white. +func Highlight(v string) string { + return cp(cBold, v) +} + +// Muted wraps v in dim white. +func Muted(v string) string { + return cp(cDim, v) +} + +// GreenText wraps v in green. +func GreenText(v string) string { + return cp(cGreen, v) +} + +// RedText wraps v in red. +func RedText(v string) string { + return cp(cRed, v) +} + +// YellowText wraps v in yellow. +func YellowText(v string) string { + return cp(cYellow, v) +} + +// CyanText wraps v in cyan. +func CyanText(v string) string { + return cp(cCyan, v) +} diff --git a/internal/cliui/live.go b/internal/cliui/live.go index 0338fa5..61eebb4 100644 --- a/internal/cliui/live.go +++ b/internal/cliui/live.go @@ -47,38 +47,50 @@ func (r *LiveRenderer) Handle(ev model.StageEvent) { r.target = ev.Target r.scanStart = chooseTime(ev.Timestamp, time.Now()) r.stage = "" - r.message = "initializing workflow" + r.message = "initializing" r.stageStart = time.Now() - r.printLinef("%s target=%s", r.info("SCAN"), r.strong(ev.Target)) + r.printLinef("%s target: %s", r.info("SCAN"), r.strong(ev.Target)) r.startSpinnerLocked() case model.EventStageStart: r.stage = ev.Stage r.message = ev.Message r.stageStart = chooseTime(ev.Timestamp, time.Now()) - r.printLinef("%s stage=%s %s", r.info("RUN"), r.stageLabel(ev.Stage), r.dim(ev.Message)) + r.printLinef("%s [%s] %s", r.info("RUN"), r.stageLabel(ev.Stage), r.dim(ev.Message)) case model.EventWarn: msg := ev.Message if strings.TrimSpace(msg) == "" { msg = "warning" } if ev.Stage != "" { - r.printLinef("%s stage=%s %s", r.warn("WARN"), r.stageLabel(ev.Stage), msg) + r.printLinef("%s [%s] %s", r.warnTag("WRN"), r.stageLabel(ev.Stage), msg) } else { - r.printLinef("%s %s", r.warn("WARN"), msg) + r.printLinef("%s %s", r.warnTag("WRN"), msg) } case model.EventStageDone: dur := time.Duration(ev.DurationMS) * time.Millisecond if dur <= 0 && !r.stageStart.IsZero() { dur = time.Since(r.stageStart) } - r.printLinef("%s stage=%s count=%d in %s", r.ok("DONE"), r.stageLabel(ev.Stage), ev.Count, dur.Round(time.Millisecond)) + r.printLinef("%s [%s] %s %s %s %s", + r.ok("OK "), + r.stageLabel(ev.Stage), + r.dim("count:"), + r.strong(fmt.Sprintf("%d", ev.Count)), + r.dim("in"), + r.dim(dur.Round(time.Millisecond).String()), + ) case model.EventTargetDone: total := time.Duration(ev.DurationMS) * time.Millisecond if total <= 0 && !r.scanStart.IsZero() { total = time.Since(r.scanStart) } r.stopSpinnerLocked() - r.printLinef("%s target=%s completed in %s", r.ok("COMPLETE"), r.strong(ev.Target), total.Round(time.Millisecond)) + r.printLinef("%s target: %s %s %s", + r.ok("OK "), + r.strong(ev.Target), + r.dim("completed in"), + r.dim(total.Round(time.Millisecond).String()), + ) } } @@ -107,8 +119,9 @@ func (r *LiveRenderer) stopSpinnerLocked() { } func (r *LiveRenderer) spin() { - frames := []string{"|", "/", "-", `\`} - ticker := time.NewTicker(120 * time.Millisecond) + // Braille spinner — used by Nuclei, httpx, and other ProjectDiscovery tools. + frames := []string{"⣾", "⣽", "⣻", "⣷", "⣯", "⣟", "⡿", "⢿"} + ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { @@ -130,9 +143,9 @@ func (r *LiveRenderer) spin() { line := fmt.Sprintf("%s %s %s %s %s", r.spinStyle(frames[r.spinFrame]), r.strong(r.target), - r.dim("stage="+stage), - msg, - r.dim("t="+elapsed), + r.dim("["+stage+"]"), + r.dim(msg), + r.dim("("+elapsed+")"), ) fmt.Fprintf(r.out, "\r\033[2K%s", line) r.lastPrinted = time.Now() @@ -144,7 +157,7 @@ func (r *LiveRenderer) spin() { } func (r *LiveRenderer) printLinef(format string, args ...any) { - // Avoid overwriting a spinner frame line. + // Clear the spinner line before printing a new log line. fmt.Fprint(r.out, "\r\033[2K") fmt.Fprintf(r.out, format+"\n", args...) } @@ -164,32 +177,35 @@ func (r *LiveRenderer) dim(v string) string { } func (r *LiveRenderer) info(v string) string { - return r.paint(v, "36") + return r.badge(v, "36") } func (r *LiveRenderer) ok(v string) string { - return r.paint(v, "32") + return r.badge(v, "32") } -func (r *LiveRenderer) warn(v string) string { - return r.paint(v, "33") +func (r *LiveRenderer) warnTag(v string) string { + return r.badge(v, "33") } func (r *LiveRenderer) spinStyle(v string) string { - return r.paint(v, "35") + if !r.color { + return v + } + return "\033[35m" + v + "\033[0m" } -func (r *LiveRenderer) paint(v, code string) string { +func (r *LiveRenderer) badge(v, code string) string { if !r.color { return "[" + v + "]" } - return "\033[" + code + "m[" + v + "]\033[0m" + return "\033[" + code + ";1m[" + strings.TrimSpace(v) + "]\033[0m" } func (r *LiveRenderer) stageLabel(stage string) string { stage = strings.TrimSpace(strings.ToLower(stage)) if stage == "" { - return "unknown" + return "?" } return stage } @@ -200,3 +216,4 @@ func chooseTime(v time.Time, fallback time.Time) time.Time { } return v } +