diff --git a/cmd/macaron/main.go b/cmd/macaron/main.go index d490fc8..26419d2 100644 --- a/cmd/macaron/main.go +++ b/cmd/macaron/main.go @@ -14,6 +14,7 @@ import ( "github.com/root-Manas/macaron/internal/app" "github.com/root-Manas/macaron/internal/cfg" + "github.com/root-Manas/macaron/internal/cliui" "github.com/root-Manas/macaron/internal/model" "github.com/root-Manas/macaron/internal/ui" "github.com/spf13/pflag" @@ -248,6 +249,11 @@ func run() int { start := time.Now() modeVal := model.Mode(strings.ToLower(mode)) + var renderer *cliui.LiveRenderer + if !quiet { + 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) } @@ -259,6 +265,11 @@ func run() int { Quiet: quiet, EnabledStages: app.ParseStages(stages), APIKeys: config.APIKeys, + Progress: func(ev model.StageEvent) { + if renderer != nil { + renderer.Handle(ev) + } + }, }) if err != nil { fmt.Fprintf(os.Stderr, "scan failed: %v\n", err) diff --git a/internal/app/app.go b/internal/app/app.go index 892c2e0..642accf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -46,6 +46,7 @@ type ScanArgs struct { Quiet bool EnabledStages map[string]bool APIKeys map[string]string + Progress func(model.StageEvent) } func New(home string) (*App, error) { @@ -69,6 +70,7 @@ func (a *App) Scan(ctx context.Context, args ScanArgs) ([]model.ScanResult, erro Quiet: args.Quiet, EnabledStages: args.EnabledStages, APIKeys: args.APIKeys, + Progress: args.Progress, }) if err != nil { return nil, err diff --git a/internal/cliui/live.go b/internal/cliui/live.go new file mode 100644 index 0000000..0338fa5 --- /dev/null +++ b/internal/cliui/live.go @@ -0,0 +1,202 @@ +package cliui + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/root-Manas/macaron/internal/model" +) + +type LiveRenderer struct { + out io.Writer + + mu sync.Mutex + color bool + spinnerOn bool + spinStop chan struct{} + spinFrame int + target string + stage string + message string + stageStart time.Time + scanStart time.Time + lastPrinted time.Time +} + +func NewLiveRenderer(out io.Writer) *LiveRenderer { + if out == nil { + out = os.Stdout + } + useColor := strings.TrimSpace(os.Getenv("NO_COLOR")) == "" + return &LiveRenderer{ + out: out, + color: useColor, + } +} + +func (r *LiveRenderer) Handle(ev model.StageEvent) { + r.mu.Lock() + defer r.mu.Unlock() + + switch ev.Type { + case model.EventTargetStart: + r.target = ev.Target + r.scanStart = chooseTime(ev.Timestamp, time.Now()) + r.stage = "" + r.message = "initializing workflow" + r.stageStart = time.Now() + 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)) + 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) + } else { + r.printLinef("%s %s", r.warn("WARN"), 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)) + 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)) + } +} + +func (r *LiveRenderer) Close() { + r.mu.Lock() + defer r.mu.Unlock() + r.stopSpinnerLocked() +} + +func (r *LiveRenderer) startSpinnerLocked() { + if r.spinnerOn { + return + } + r.spinnerOn = true + r.spinStop = make(chan struct{}) + go r.spin() +} + +func (r *LiveRenderer) stopSpinnerLocked() { + if !r.spinnerOn { + return + } + close(r.spinStop) + r.spinnerOn = false + fmt.Fprint(r.out, "\r\033[2K") +} + +func (r *LiveRenderer) spin() { + frames := []string{"|", "/", "-", `\`} + ticker := time.NewTicker(120 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + r.mu.Lock() + r.spinFrame = (r.spinFrame + 1) % len(frames) + stage := r.stage + if strings.TrimSpace(stage) == "" { + stage = "bootstrap" + } + msg := strings.TrimSpace(r.message) + if msg == "" { + msg = "working" + } + elapsed := "--" + if !r.stageStart.IsZero() { + elapsed = time.Since(r.stageStart).Round(time.Second).String() + } + 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), + ) + fmt.Fprintf(r.out, "\r\033[2K%s", line) + r.lastPrinted = time.Now() + r.mu.Unlock() + case <-r.spinStop: + return + } + } +} + +func (r *LiveRenderer) printLinef(format string, args ...any) { + // Avoid overwriting a spinner frame line. + fmt.Fprint(r.out, "\r\033[2K") + fmt.Fprintf(r.out, format+"\n", args...) +} + +func (r *LiveRenderer) strong(v string) string { + if !r.color { + return v + } + return "\033[1;37m" + v + "\033[0m" +} + +func (r *LiveRenderer) dim(v string) string { + if !r.color { + return v + } + return "\033[2;37m" + v + "\033[0m" +} + +func (r *LiveRenderer) info(v string) string { + return r.paint(v, "36") +} + +func (r *LiveRenderer) ok(v string) string { + return r.paint(v, "32") +} + +func (r *LiveRenderer) warn(v string) string { + return r.paint(v, "33") +} + +func (r *LiveRenderer) spinStyle(v string) string { + return r.paint(v, "35") +} + +func (r *LiveRenderer) paint(v, code string) string { + if !r.color { + return "[" + v + "]" + } + return "\033[" + code + "m[" + v + "]\033[0m" +} + +func (r *LiveRenderer) stageLabel(stage string) string { + stage = strings.TrimSpace(strings.ToLower(stage)) + if stage == "" { + return "unknown" + } + return stage +} + +func chooseTime(v time.Time, fallback time.Time) time.Time { + if v.IsZero() { + return fallback + } + return v +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 79d0c50..56c43e2 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -30,6 +30,7 @@ type Options struct { Quiet bool EnabledStages map[string]bool APIKeys map[string]string + Progress func(model.StageEvent) } type Engine struct { @@ -55,6 +56,12 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m Mode: normalizeMode(opts.Mode), StartedAt: start, } + emit(opts.Progress, model.StageEvent{ + Timestamp: start, + Type: model.EventTargetStart, + Target: result.Target, + Message: "scan started", + }) if opts.Threads <= 0 { opts.Threads = 30 } @@ -65,9 +72,12 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m subs := map[string]struct{}{result.Target: {}} if stageOn(opts.EnabledStages, "subdomains") { + stageStart := time.Now() + emit(opts.Progress, model.StageEvent{Timestamp: stageStart, Type: model.EventStageStart, Target: result.Target, Stage: "subdomains", Message: "collecting subdomains"}) nativeSubs, warn := e.crtshSubdomains(ctx, result.Target) if warn != "" { result.Warnings = append(result.Warnings, warn) + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "subdomains", Message: warn}) } for _, s := range nativeSubs { if looksLikeHost(s) { @@ -96,8 +106,17 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m } } } + emit(opts.Progress, model.StageEvent{ + Timestamp: time.Now(), + Type: model.EventStageDone, + Target: result.Target, + Stage: "subdomains", + Count: len(subs), + DurationMS: time.Since(stageStart).Milliseconds(), + }) } else { result.Warnings = append(result.Warnings, "subdomain stage disabled by --stages") + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "subdomains", Message: "stage disabled"}) } result.Subdomains = mapKeys(subs) @@ -109,28 +128,63 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m live := make([]model.LiveHost, 0) if stageOn(opts.EnabledStages, "http") { + stageStart := time.Now() + emit(opts.Progress, model.StageEvent{Timestamp: stageStart, Type: model.EventStageStart, Target: result.Target, Stage: "http", Message: "probing live hosts"}) live = e.probeHTTP(ctx, probeInputs, opts.Threads) result.LiveHosts = live + emit(opts.Progress, model.StageEvent{ + Timestamp: time.Now(), + Type: model.EventStageDone, + Target: result.Target, + Stage: "http", + Count: len(result.LiveHosts), + DurationMS: time.Since(stageStart).Milliseconds(), + }) } else { result.Warnings = append(result.Warnings, "http stage disabled by --stages") + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "http", Message: "stage disabled"}) } if stageOn(opts.EnabledStages, "ports") { + stageStart := time.Now() + emit(opts.Progress, model.StageEvent{Timestamp: stageStart, Type: model.EventStageStart, Target: result.Target, Stage: "ports", Message: "scanning common ports"}) ports := scanCommonPorts(probeInputs, opts.Threads) result.Ports = ports + emit(opts.Progress, model.StageEvent{ + Timestamp: time.Now(), + Type: model.EventStageDone, + Target: result.Target, + Stage: "ports", + Count: len(result.Ports), + DurationMS: time.Since(stageStart).Milliseconds(), + }) } else { result.Warnings = append(result.Warnings, "port stage disabled by --stages") + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "ports", Message: "stage disabled"}) } if stageOn(opts.EnabledStages, "urls") { + stageStart := time.Now() + emit(opts.Progress, model.StageEvent{Timestamp: stageStart, Type: model.EventStageStart, Target: result.Target, Stage: "urls", Message: "discovering URLs"}) urls := e.discoverURLs(ctx, probeInputs, opts.Threads) result.URLs = urls result.JSFiles = extractJS(urls) + emit(opts.Progress, model.StageEvent{ + Timestamp: time.Now(), + Type: model.EventStageDone, + Target: result.Target, + Stage: "urls", + Count: len(result.URLs), + DurationMS: time.Since(stageStart).Milliseconds(), + }) } else { result.Warnings = append(result.Warnings, "url discovery stage disabled by --stages") + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "urls", Message: "stage disabled"}) } if stageOn(opts.EnabledStages, "vulns") { + stageStart := time.Now() + emit(opts.Progress, model.StageEvent{Timestamp: stageStart, Type: model.EventStageStart, Target: result.Target, Stage: "vulns", Message: "running vulnerability checks"}) if hasBinary("nuclei") { vulns, err := runNuclei(ctx, live) if err == nil { @@ -138,9 +192,19 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m } } else { result.Warnings = append(result.Warnings, "nuclei not installed: vulnerability stage skipped") + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "vulns", Message: "nuclei missing; skipped"}) } + emit(opts.Progress, model.StageEvent{ + Timestamp: time.Now(), + Type: model.EventStageDone, + Target: result.Target, + Stage: "vulns", + Count: len(result.Vulns), + DurationMS: time.Since(stageStart).Milliseconds(), + }) } else { result.Warnings = append(result.Warnings, "vulnerability stage disabled by --stages") + emit(opts.Progress, model.StageEvent{Timestamp: time.Now(), Type: model.EventWarn, Target: result.Target, Stage: "vulns", Message: "stage disabled"}) } result.Stats = model.ScanStats{ @@ -153,9 +217,22 @@ func (e *Engine) ScanTarget(ctx context.Context, target string, opts Options) (m } result.FinishedAt = time.Now() result.DurationMS = result.FinishedAt.Sub(start).Milliseconds() + emit(opts.Progress, model.StageEvent{ + Timestamp: result.FinishedAt, + Type: model.EventTargetDone, + Target: result.Target, + Message: "scan completed", + DurationMS: result.DurationMS, + }) return result, nil } +func emit(cb func(model.StageEvent), ev model.StageEvent) { + if cb != nil { + cb(ev) + } +} + func normalizeMode(m model.Mode) model.Mode { switch m { case model.ModeFast, model.ModeNarrow, model.ModeWide, model.ModeDeep, model.ModeOSINT: diff --git a/internal/model/model.go b/internal/model/model.go index 7b8a60d..1955361 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -69,3 +69,23 @@ type ToolStatus struct { Name string `json:"name"` Installed bool `json:"installed"` } + +type StageEventType string + +const ( + EventTargetStart StageEventType = "target_start" + EventTargetDone StageEventType = "target_done" + EventStageStart StageEventType = "stage_start" + EventStageDone StageEventType = "stage_done" + EventWarn StageEventType = "warn" +) + +type StageEvent struct { + Timestamp time.Time `json:"timestamp"` + Type StageEventType `json:"type"` + Target string `json:"target"` + Stage string `json:"stage,omitempty"` + Message string `json:"message,omitempty"` + Count int `json:"count,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} diff --git a/internal/ui/assets/index.html b/internal/ui/assets/index.html index 6c56cd5..18a1c6b 100644 --- a/internal/ui/assets/index.html +++ b/internal/ui/assets/index.html @@ -70,6 +70,8 @@ color: var(--accent); background: #0a1723; } + .pill.warn { color: var(--warning); } + .pill-grid { display: flex; gap: 8px; align-items: center; } .panel { border: 1px solid var(--line); border-radius: var(--radius); @@ -178,6 +180,28 @@ .muted { color: var(--muted); } .mono { font-family: "IBM Plex Mono", monospace; } + .status-strip { + border: 1px solid var(--line); + border-radius: 10px; + background: #0a1621; + padding: 8px 10px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 12px; + } + .btn { + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 10px; + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + color: var(--ink); + background: #0c1a27; + cursor: pointer; + } + .btn:hover { border-color: var(--accent); color: var(--accent); } @media (max-width: 1260px) { .shell { grid-template-columns: 1fr; } @@ -194,7 +218,10 @@

macaronV2 OPS Console

Intent-first recon: setup -> scan -> inspect -> export
-
loading scans...
+
+
loading scans...
+
refreshing...
+