Repository: google/osv-scanner

Issue:
Allow configuration of the library's logging
The OpenSSF Scorecard project uses osv-scanner as a library, specifically `osvscanner.DoScan`. I experimented briefly with the migration to v2 recently and started getting a flood of logs to our stderr which are originating from osv-scanner.

```
2025/07/16 11:03:49 INFO Scanning dir <...>
2025/07/16 11:04:15 ERROR Error during extraction: (extracting as java/pomxmlnet) failed resolving
2025/07/16 11:04:39 INFO Scanned <...>/package-lock.json file and found 1568 packages
```

Is there anyway to introduce a control to disable `cmdlogger`'s output? It seems most of the codebase uses `cmdlogger.Info` (and friends), which write to the default (global) slog handler.
https://github.com/google/osv-scanner/blob/6515dc09a74355444b56c7dbf85b025bf9161b94/internal/cmdlogger/fmt.go#L8-L22

I can set the slog default to something which would ignore it, like [slog.DiscardHandler](https://pkg.go.dev/log/slog#DiscardHandler), but this would break many of our own logging.

Hints from issue comments:
@spencerschrock: Maybe instead of using the global default and setting it with `slog.SetDefault` in your various binaries, you can have a package level variable such as `cmdlogger.Default`, and then the various helpers can call `cmdlogger.Default.Info` etc instead of `slog.Info`?

Since this is an internal package, we would still have problems setting/clearing `cmdlogger.Default`@another-rex: A perhaps hacky fix: 
```go
origHandler :=  slog.GetDefault() // Or something similar
slog.SetDefault(slog.DiscardHandler)
osvscanner.Run(...)
slog.SetDefault(origHandler)
```

This would only reliably work if scorecard is running osv-scanner non concurrently. @another-rex: > Maybe instead of using the global default and setting it with `slog.SetDefault` in your various binaries, you can have a package level variable such as `cmdlogger.Default`, and then the various helpers can call `cmdlogger.Default.Info` etc instead of `slog.Info`?
> 
> Since this is an internal package, we would still have problems setting/clearing `cmdlogger.Default`

We can definitely look into that, I think we can just make a public function in osvscanner that sets the default used in cmdlogger to fix that issue. @spencerschrock: > A perhaps hacky fix:
> 
> origHandler :=  slog.GetDefault() // Or something similar
> slog.SetDefault(slog.DiscardHandler)
> osvscanner.Run(...)
> slog.SetDefault(origHandler)
> This would only reliably work if scorecard is running osv-scanner non concurrently.

All of the checks run in a separate goroutine, so there is concurrency to worry about.@spencerschrock: This is still an issue on v2.3.3, I'll send a patch

Solution patch:
diff --git a/internal/cmdlogger/fmt.go b/internal/cmdlogger/fmt.go
index 1f87441b..ccd34696 100644
--- a/internal/cmdlogger/fmt.go
+++ b/internal/cmdlogger/fmt.go
@@ -2,22 +2,27 @@
 package cmdlogger
 
 import (
+	"cmp"
 	"fmt"
 	"log/slog"
 )
 
 func Debugf(msg string, args ...any) {
-	slog.Debug(fmt.Sprintf(msg, args...))
+	logger := cmp.Or(GlobalLogger, slog.Default())
+	logger.Debug(fmt.Sprintf(msg, args...))
 }
 
 func Infof(msg string, args ...any) {
-	slog.Info(fmt.Sprintf(msg, args...))
+	logger := cmp.Or(GlobalLogger, slog.Default())
+	logger.Info(fmt.Sprintf(msg, args...))
 }
 
 func Warnf(msg string, args ...any) {
-	slog.Warn(fmt.Sprintf(msg, args...))
+	logger := cmp.Or(GlobalLogger, slog.Default())
+	logger.Warn(fmt.Sprintf(msg, args...))
 }
 
 func Errorf(msg string, args ...any) {
-	slog.Error(fmt.Sprintf(msg, args...))
+	logger := cmp.Or(GlobalLogger, slog.Default())
+	logger.Error(fmt.Sprintf(msg, args...))
 }
diff --git a/internal/cmdlogger/handler.go b/internal/cmdlogger/handler.go
index 71da7938..3fc2c326 100644
--- a/internal/cmdlogger/handler.go
+++ b/internal/cmdlogger/handler.go
@@ -8,14 +8,17 @@ import (
 	"strings"
 )
 
-var GlobalHandler slog.Handler
+var (
+	GlobalLogger *slog.Logger
+)
 
 type Handler struct {
 	stdout             io.Writer
 	stderr             io.Writer
 	hasErrored         bool
 	everythingToStderr bool
-	Level              slog.Leveler
+	level              slog.Leveler
+	overrideHandler    slog.Handler
 
 	hasErroredBecauseInvalidConfig bool
 }
@@ -30,7 +33,7 @@ func (c *Handler) SendEverythingToStderr() {
 }
 
 func (c *Handler) SetLevel(level slog.Leveler) {
-	c.Level = level
+	c.level = level
 }
 
 func (c *Handler) writer(level slog.Level) io.Writer {
@@ -46,11 +49,11 @@ func (c *Handler) Enabled(ctx context.Context, level slog.Level) bool {
 		c.hasErrored = true
 	}
 
-	if GlobalHandler != nil {
-		return GlobalHandler.Enabled(ctx, level)
+	if c.overrideHandler != nil {
+		return c.overrideHandler.Enabled(ctx, level)
 	}
 
-	return level >= c.Level.Level()
+	return level >= c.level.Level()
 }
 
 func (c *Handler) Handle(ctx context.Context, record slog.Record) error {
@@ -62,8 +65,8 @@ func (c *Handler) Handle(ctx context.Context, record slog.Record) error {
 		}
 	}
 
-	if GlobalHandler != nil {
-		return GlobalHandler.Handle(ctx, record)
+	if c.overrideHandler != nil {
+		return c.overrideHandler.Handle(ctx, record)
 	}
 
 	_, err := fmt.Fprint(c.writer(record.Level), record.Message+"\n")
@@ -84,15 +87,15 @@ func (c *Handler) HasErroredBecauseInvalidConfig() bool {
 }
 
 func (c *Handler) WithAttrs(a []slog.Attr) slog.Handler {
-	if GlobalHandler != nil {
-		return GlobalHandler.WithAttrs(a)
+	if c.overrideHandler != nil {
+		return c.overrideHandler.WithAttrs(a)
 	}
 	panic("not supported")
 }
 
 func (c *Handler) WithGroup(g string) slog.Handler {
-	if GlobalHandler != nil {
-		return GlobalHandler.WithGroup(g)
+	if c.overrideHandler != nil {
+		return c.overrideHandler.WithGroup(g)
 	}
 	panic("not supported")
 }
@@ -103,6 +106,12 @@ func New(stdout, stderr io.Writer) CmdLogger {
 	return &Handler{
 		stdout: stdout,
 		stderr: stderr,
-		Level:  slog.LevelInfo,
+		level:  slog.LevelInfo,
+	}
+}
+
+func NewOverride(overrideHandler slog.Handler) CmdLogger {
+	return &Handler{
+		overrideHandler: overrideHandler,
 	}
 }
diff --git a/internal/cmdlogger/scalibr.go b/internal/cmdlogger/scalibr.go
new file mode 100644
index 00000000..6c23a8ab
--- /dev/null
+++ b/internal/cmdlogger/scalibr.go
@@ -0,0 +1,46 @@
+package cmdlogger
+
+import (
+	"fmt"
+	"log/slog"
+
+	"github.com/google/osv-scalibr/log"
+)
+
+var _ log.Logger = &ScalibrAdapter{}
+
+type ScalibrAdapter struct {
+	Logger *slog.Logger
+}
+
+func (s *ScalibrAdapter) Errorf(format string, args ...any) {
+	s.Logger.Error(fmt.Sprintf(format, args...))
+}
+
+func (s *ScalibrAdapter) Error(args ...any) {
+	s.Logger.Error(fmt.Sprint(args...))
+}
+
+func (s *ScalibrAdapter) Warnf(format string, args ...any) {
+	s.Logger.Warn(fmt.Sprintf(format, args...))
+}
+
+func (s *ScalibrAdapter) Warn(args ...any) {
+	s.Logger.Warn(fmt.Sprint(args...))
+}
+
+func (s *ScalibrAdapter) Infof(format string, args ...any) {
+	s.Logger.Info(fmt.Sprintf(format, args...))
+}
+
+func (s *ScalibrAdapter) Info(args ...any) {
+	s.Logger.Info(fmt.Sprint(args...))
+}
+
+func (s *ScalibrAdapter) Debugf(format string, args ...any) {
+	s.Logger.Debug(fmt.Sprintf(format, args...))
+}
+
+func (s *ScalibrAdapter) Debug(args ...any) {
+	s.Logger.Debug(fmt.Sprint(args...))
+}
diff --git a/pkg/osvscanner/osvscanner.go b/pkg/osvscanner/osvscanner.go
index 793d1f0e..bcfe022a 100644
--- a/pkg/osvscanner/osvscanner.go
+++ b/pkg/osvscanner/osvscanner.go
@@ -21,6 +21,7 @@ import (
 	"github.com/google/osv-scalibr/enricher/reachability/java"
 	"github.com/google/osv-scalibr/extractor"
 	"github.com/google/osv-scalibr/inventory"
+	scalibrlog "github.com/google/osv-scalibr/log"
 	"github.com/google/osv-scalibr/plugin"
 	"github.com/google/osv-scalibr/stats"
 	"github.com/google/osv-scanner/v2/internal/clients/clientimpl/licensematcher"
@@ -561,7 +562,10 @@ func overrideGoVersion(scanResults *results.ScanResults) {
 
 // SetLogger sets the global slog handler for the cmdlogger.
 func SetLogger(handler slog.Handler) {
-	cmdlogger.GlobalHandler = handler
+	baseHandler := cmdlogger.NewOverride(handler)
+	logger := slog.New(baseHandler)
+	cmdlogger.GlobalLogger = logger
+	scalibrlog.SetLogger(&cmdlogger.ScalibrAdapter{Logger: logger})
 }
 
 // inventoryIsEmpty ignores image metadata when checking if an inventory is empty



Test patch:
diff --git a/internal/testlogger/markers.go b/internal/testlogger/markers.go
index 1204ae18..dcb7546b 100644
--- a/internal/testlogger/markers.go
+++ b/internal/testlogger/markers.go
@@ -1,8 +1,9 @@
 package testlogger
 
 import (
-	"log/slog"
 	"testing"
+
+	"github.com/google/osv-scanner/v2/internal/cmdlogger"
 )
 
 const BeginDirectoryScan = "---Begin Directory Scan---"
@@ -12,13 +13,13 @@ const EndDirectoryScan = "---End Directory Scan---"
 // the scanning order to allow for unsorted file walks.
 func BeginDirScanMarker() {
 	if testing.Testing() {
-		slog.Info(BeginDirectoryScan)
+		cmdlogger.Infof(BeginDirectoryScan)
 	}
 }
 
 // EndDirScanMarker prints out a directory scanning marker during testing to mark the end of directory walks
 func EndDirScanMarker() {
 	if testing.Testing() {
-		slog.Info(EndDirectoryScan)
+		cmdlogger.Infof(EndDirectoryScan)
 	}
 }
diff --git a/pkg/osvscanner/osvscanner_test.go b/pkg/osvscanner/osvscanner_test.go
new file mode 100644
index 00000000..80786125
--- /dev/null
+++ b/pkg/osvscanner/osvscanner_test.go
@@ -0,0 +1,54 @@
+package osvscanner_test
+
+import (
+	"bytes"
+	"log/slog"
+	"testing"
+
+	"github.com/google/osv-scanner/v2/pkg/osvscanner"
+)
+
+// TestDoScan_LogHandlerOverride tests that the SetLogger override works correctly
+//
+//nolint:paralleltest // No parallel test since slog.SetDefault sets global behavior
+func TestDoScan_LogHandlerOverride(t *testing.T) {
+	// Restore default slog behavior at the ned of the test
+	defaultHandler := slog.Default()
+	defer func() {
+		slog.SetDefault(defaultHandler)
+	}()
+
+	actions := osvscanner.ScannerActions{
+		DirectoryPaths: []string{"../../cmd/osv-scanner/testdata/locks-many/Gemfile.lock"},
+	}
+
+	output := bytes.NewBuffer(nil)
+	slog.SetDefault(slog.New(slog.NewTextHandler(output, nil)))
+
+	_, _ = osvscanner.DoScan(actions)
+
+	// Test that normally logging is output correctly to the default slog handler.
+	if output.Len() == 0 {
+		t.Errorf("output.Len() = %d, want %d", output.Len(), 0)
+	}
+
+	// Clear output buffer for next run
+	output.Truncate(0)
+
+	// Test if output is overridden
+	altOutput := bytes.NewBuffer(nil)
+	osvscanner.SetLogger(slog.NewTextHandler(altOutput, nil))
+
+	_, _ = osvscanner.DoScan(actions)
+
+	// Normal slog output should be empty.
+	if output.Len() != 0 {
+		t.Errorf("output.Len() = %d, want %d", output.Len(), 0)
+		t.Errorf("Got: %s", output.String())
+	}
+
+	// altOutput should contain data now instead.
+	if altOutput.Len() == 0 {
+		t.Errorf("altOutput.Len() = %d, want %d", altOutput.Len(), 0)
+	}
+}