Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ to promote a run to current state. See [docs/transport.md](docs/transport.md)
for HTTPS/file output and [docs/state-model.md](docs/state-model.md) for the
receiver-side current-state model.

For interactive terminal use, `bumblebee scan --output=terminal` renders a
colorized summary, a compact findings table, and a live spinner on stderr
while the scan runs. The default `stdout` mode remains NDJSON for tooling.

Package record:

<details>
Expand Down
75 changes: 66 additions & 9 deletions cmd/bumblebee/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
// version.go — Version variable and the version-string formatters
// sink.go — --output stdout|file|http construction and HTTP auth
//
// Output destinations: stdout (default), a local NDJSON file, or POST to a
// generic HTTPS log-ingest endpoint. See `scan --help` and the README.
// Output destinations: stdout (default), a local NDJSON file, a human-readable
// terminal report, or POST to a generic HTTPS log-ingest endpoint. See
// `scan --help` and the README.
package main

import (
Expand Down Expand Up @@ -114,6 +115,7 @@ type scanOpts struct {
outputFile string
appendFile bool
emitSummary bool
terminalMode bool
httpURL string
httpAuth string
httpTokenEnv string
Expand Down Expand Up @@ -146,7 +148,8 @@ func registerScanFlags(fs *flag.FlagSet, o *scanOpts) {
fs.BoolVar(&o.allUsers, "all-users", false,
"on macOS, expand baseline/project per-user default roots across every real /Users/<name>/ home. Useful for root-owned LaunchDaemon runs. Cannot be combined with --root or --profile=deep. System/Homebrew roots are still included once. No effect on Linux.")

fs.StringVar(&o.outputDest, "output", "stdout", "where to send records: stdout, file, or http")
fs.StringVar(&o.outputDest, "output", "stdout", "where to send records: stdout, file, http, or terminal")
fs.BoolVar(&o.terminalMode, "terminal", false, "render a human-readable terminal report instead of raw NDJSON (implies --output=terminal)")
fs.StringVar(&o.outputFile, "output-file", "", "path for --output=file (NDJSON; required when --output=file)")
fs.BoolVar(&o.appendFile, "append", false, "append to --output-file instead of truncating")
fs.BoolVar(&o.emitSummary, "emit-summary", true, "emit a scan_summary record at end of run")
Expand Down Expand Up @@ -187,6 +190,9 @@ func runScan(args []string) int {
fmt.Fprintln(os.Stderr, "--findings-only requires --exposure-catalog")
return 2
}
if o.terminalMode {
o.outputDest = "terminal"
}

roots, diagNotes, err := resolveRoots(o.profile, o.roots, rootsOpts{AllUsers: o.allUsers})
if err != nil {
Expand Down Expand Up @@ -220,13 +226,26 @@ func runScan(args []string) int {
}

runID := newRunID()
emitter := output.New(recordsW, os.Stderr, runID)
diagW := io.Writer(os.Stderr)
if o.outputDest == "terminal" {
diagW = io.Discard
}
Comment on lines +233 to +235
Comment on lines +232 to +235
emitter := output.New(recordsW, diagW, runID)

for _, n := range diagNotes {
emitter.Diag("info", "", n)
if o.outputDest == "terminal" {
for _, n := range diagNotes {
fmt.Fprintln(os.Stderr, n)
}
} else {
for _, n := range diagNotes {
emitter.Diag("info", "", n)
}
}
deviceID, deviceIDWarn := resolveDeviceID(o.deviceIDEnv)
if deviceIDWarn != "" {
if deviceIDWarn != "" && o.outputDest == "terminal" {
fmt.Fprintln(os.Stderr, deviceIDWarn)
}
if deviceIDWarn != "" && o.outputDest != "terminal" {
emitter.Diag("warn", "", deviceIDWarn)
}
ep := endpoint.Current(deviceID)
Expand All @@ -251,6 +270,12 @@ func runScan(args []string) int {
cancel()
}()

spinnerDone := make(chan struct{})
spinnerFinished := make(chan struct{})
if o.outputDest == "terminal" {
go runSpinner(os.Stderr, spinnerDone, spinnerFinished)
}

cfg := scanner.Config{
Profile: o.profile,
Roots: roots,
Expand All @@ -265,8 +290,16 @@ func runScan(args []string) int {
Emitter: emitter,
}
res, runErr := scanner.Run(ctx, cfg)
if o.outputDest == "terminal" {
close(spinnerDone)
<-spinnerFinished
}
Comment on lines +296 to +299
if runErr != nil {
emitter.Diag("error", "", runErr.Error())
if o.outputDest == "terminal" {
fmt.Fprintln(os.Stderr, runErr.Error())
} else {
emitter.Diag("error", "", runErr.Error())
}
}

exitCode := 0
Expand Down Expand Up @@ -332,7 +365,11 @@ func runScan(args []string) int {
}

if closeErr := closeFn(); closeErr != nil {
emitter.Diag("error", "", closeErr.Error())
if o.outputDest == "terminal" {
fmt.Fprintln(os.Stderr, closeErr.Error())
} else {
emitter.Diag("error", "", closeErr.Error())
}
exitCode = 1
}

Expand Down Expand Up @@ -378,6 +415,26 @@ func runRoots(args []string) int {
return 0
}

func runSpinner(w io.Writer, done <-chan struct{}, finished chan<- struct{}) {
defer close(finished)
frames := []string{"|", "/", "-", "\\"}
ticker := time.NewTicker(120 * time.Millisecond)
defer ticker.Stop()
idx := 0
_, _ = fmt.Fprint(w, "scanning ")
for {
select {
case <-done:
_, _ = fmt.Fprint(w, "\r")
_, _ = fmt.Fprintln(w, "scanning complete")
return
case <-ticker.C:
_, _ = fmt.Fprintf(w, "\rscanning %s", frames[idx%len(frames)])
idx++
}
}
Comment on lines +473 to +488
}

// resolveDeviceID reads the configured env var and returns the trimmed
// device id. If the flag was unset, both returns are empty. If the flag
// was set but the env var is missing or whitespace-only, the id is empty
Expand Down
9 changes: 6 additions & 3 deletions cmd/bumblebee/sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ type sinkHTTPOpts struct {

// openSink returns the io.Writer the emitter should write records to,
// along with a close function to invoke at end-of-scan. dest selects the
// kind ("stdout", "file", or "http"); filePath and appendMode only
// apply when dest=="file"; h carries the HTTP-sink options when
// kind ("stdout", "terminal", "file", or "http"); filePath and appendMode
// only apply when dest=="file"; h carries the HTTP-sink options when
// dest=="http".
//
// The returned close function is always non-nil. For stdout it is a
Expand All @@ -36,6 +36,9 @@ func openSink(dest, filePath string, appendMode bool, h sinkHTTPOpts) (io.Writer
switch dest {
case "", "stdout":
return os.Stdout, func() error { return nil }, nil
case "terminal":
sink := output.NewTerminalSink(os.Stdout)
return sink, sink.Close, nil
case "file":
if filePath == "" {
return nil, nil, fmt.Errorf("--output=file requires --output-file")
Expand Down Expand Up @@ -70,7 +73,7 @@ func openSink(dest, filePath string, appendMode bool, h sinkHTTPOpts) (io.Writer
}
return sink, sink.Close, nil
default:
return nil, nil, fmt.Errorf("unknown --output %q (want stdout|file|http)", dest)
return nil, nil, fmt.Errorf("unknown --output %q (want stdout|file|http|terminal)", dest)
}
}

Expand Down
Loading