diff --git a/README.md b/README.md index 0776dc0..0f83b4c 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,9 @@ optional for the other profiles. `--ecosystem` is repeatable and comma-separated. `--exposure-catalog` accepts a JSON file or a directory of `*.json` catalogs (merged non-recursively, all files must share `schema_version`). `--findings-only` requires `--exposure-catalog` and -suppresses package records while keeping findings. `bumblebee scan --help` -lists every flag. +suppresses package records while keeping findings. `--quiet` suppresses +info-level diagnostics on stderr while keeping warn and error diagnostics. +`bumblebee scan --help` lists every flag. ## Output diff --git a/cmd/bumblebee/main.go b/cmd/bumblebee/main.go index 476fe1a..04e6c8a 100644 --- a/cmd/bumblebee/main.go +++ b/cmd/bumblebee/main.go @@ -124,6 +124,8 @@ type scanOpts struct { httpGzip bool deviceIDEnv string + + quiet bool } func registerScanFlags(fs *flag.FlagSet, o *scanOpts) { @@ -161,6 +163,8 @@ func registerScanFlags(fs *flag.FlagSet, o *scanOpts) { fs.StringVar(&o.deviceIDEnv, "device-id-env", "", "env var holding a stable opaque endpoint/device id (e.g. set by MDM, EDR, or a provisioning script); populates endpoint.device_id when set") + + fs.BoolVar(&o.quiet, "quiet", false, "suppress info-level diagnostics on stderr (warn and error diagnostics are still emitted)") } // runScan executes the scan subcommand. Returns the process exit code. @@ -221,6 +225,7 @@ func runScan(args []string) int { runID := newRunID() emitter := output.New(recordsW, os.Stderr, runID) + emitter.SetQuiet(o.quiet) for _, n := range diagNotes { emitter.Diag("info", "", n) diff --git a/internal/output/output.go b/internal/output/output.go index b903b3e..ab4591a 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -35,6 +35,7 @@ type Emitter struct { records io.Writer diags io.Writer runID string + quiet bool mu sync.Mutex enc *json.Encoder @@ -57,6 +58,14 @@ func New(records, diags io.Writer, runID string) *Emitter { } } +// SetQuiet suppresses info-level diagnostics on the diagnostics writer. +// Warn and error diagnostics are still emitted. +func (e *Emitter) SetQuiet(quiet bool) { + e.mu.Lock() + defer e.mu.Unlock() + e.quiet = quiet +} + // ObservePackage reserves the package record's dedupe slot and returns the // canonicalized record plus whether it is new for this run. func (e *Emitter) ObservePackage(r model.Record) (model.Record, bool) { @@ -132,6 +141,9 @@ func (e *Emitter) Diag(level, path, msg string) { e.mu.Lock() defer e.mu.Unlock() e.Diagnostics++ + if e.quiet && level == "info" { + return + } d := model.Diagnostic{ RecordType: model.RecordTypeDiagnostic, RunID: e.runID, diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 81a4071..356f48a 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -110,6 +110,39 @@ func TestEmitSummaryWritesScanSummaryRecord(t *testing.T) { } } +func TestDiagQuietSuppressesInfo(t *testing.T) { + var diags bytes.Buffer + e := New(io.Discard, &diags, "run-1") + e.SetQuiet(true) + e.Diag("info", "", "resolved roots") + if diags.Len() != 0 { + t.Fatalf("quiet mode wrote info diagnostic: %q", diags.String()) + } + if e.Diagnostics != 1 { + t.Fatalf("Diagnostics=%d, want 1 (info still counted)", e.Diagnostics) + } +} + +func TestDiagQuietStillEmitsWarnAndError(t *testing.T) { + var diags bytes.Buffer + e := New(io.Discard, &diags, "run-1") + e.SetQuiet(true) + e.Diag("warn", "", "device id env not set") + e.Diag("error", "", "scan failed") + if got := strings.Count(diags.String(), "\n"); got != 2 { + t.Fatalf("want 2 diagnostic lines, got %d: %q", got, diags.String()) + } +} + +func TestDiagDefaultEmitsInfo(t *testing.T) { + var diags bytes.Buffer + e := New(io.Discard, &diags, "run-1") + e.Diag("info", "", "scan complete") + if diags.Len() == 0 { + t.Fatal("default mode should emit info diagnostic") + } +} + func TestObservePackageDedupsWithoutWriting(t *testing.T) { e := New(io.Discard, io.Discard, "run-1") rec := model.Record{