From 416812d702ad03c578a832071b1a2540bbab2646 Mon Sep 17 00:00:00 2001 From: devopscomputer Date: Sat, 16 May 2026 21:24:28 -0300 Subject: [PATCH] feat: introduce top-level plugins list command to display active scalibr plugins --- cmd/osv-scanner/main.go | 2 + cmd/osv-scanner/plugins/command.go | 45 +++++++++++++++++ cmd/osv-scanner/plugins/command_test.go | 58 ++++++++++++++++++++++ cmd/osv-scanner/plugins/testmain_test.go | 21 ++++++++ cmd/osv-scanner/testmain_test.go | 2 + internal/scalibrplugin/resolve.go | 61 ++++++++++++++++++++++++ internal/scalibrplugin/resolve_test.go | 28 +++++++++++ 7 files changed, 217 insertions(+) create mode 100644 cmd/osv-scanner/plugins/command.go create mode 100644 cmd/osv-scanner/plugins/command_test.go create mode 100644 cmd/osv-scanner/plugins/testmain_test.go diff --git a/cmd/osv-scanner/main.go b/cmd/osv-scanner/main.go index 17cb237c9d9..7307031359e 100644 --- a/cmd/osv-scanner/main.go +++ b/cmd/osv-scanner/main.go @@ -6,6 +6,7 @@ import ( "github.com/google/osv-scanner/v2/cmd/osv-scanner/fix" "github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/cmd" "github.com/google/osv-scanner/v2/cmd/osv-scanner/mcp" + "github.com/google/osv-scanner/v2/cmd/osv-scanner/plugins" "github.com/google/osv-scanner/v2/cmd/osv-scanner/scan" "github.com/google/osv-scanner/v2/cmd/osv-scanner/update" ) @@ -15,6 +16,7 @@ func main() { cmd.Run(os.Args, os.Stdout, os.Stderr, nil, []cmd.CommandBuilder{ scan.Command, fix.Command, + plugins.Command, update.Command, mcp.Command, }), diff --git a/cmd/osv-scanner/plugins/command.go b/cmd/osv-scanner/plugins/command.go new file mode 100644 index 00000000000..1f8a226249e --- /dev/null +++ b/cmd/osv-scanner/plugins/command.go @@ -0,0 +1,45 @@ +package plugins + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/google/osv-scanner/v2/internal/scalibrplugin" + "github.com/urfave/cli/v3" +) + +func Command(stdout, _ io.Writer, _ *http.Client) *cli.Command { + return &cli.Command{ + Name: "plugins", + Usage: "lists the available experimental plugin presets and exact plugin names", + Description: "Lists the available experimental plugin presets and exact plugin names that can be passed to --experimental-plugins or --experimental-disable-plugins.", + Commands: []*cli.Command{ + { + Name: "list", + Usage: "lists the available plugin presets and exact plugin names", + Description: "Lists the available experimental plugin presets and exact plugin names that can be passed to --experimental-plugins or --experimental-disable-plugins.", + Action: func(_ context.Context, _ *cli.Command) error { + printPluginCatalog(stdout) + return nil + }, + }, + }, + } +} + +func printPluginCatalog(stdout io.Writer) { + fmt.Fprintln(stdout, "Available plugin presets:") + fmt.Fprintf(stdout, " extractors: %s\n", strings.Join(scalibrplugin.ExtractorPresetNames(), ", ")) + fmt.Fprintf(stdout, " detectors: %s\n", strings.Join(scalibrplugin.DetectorPresetNames(), ", ")) + fmt.Fprintf(stdout, " annotators: %s\n", strings.Join(scalibrplugin.AnnotatorPresetNames(), ", ")) + fmt.Fprintf(stdout, " enrichers: %s\n", strings.Join(scalibrplugin.EnricherPresetNames(), ", ")) + fmt.Fprintln(stdout) + + fmt.Fprintln(stdout, "Available exact plugin names:") + for _, name := range scalibrplugin.PluginNames() { + fmt.Fprintf(stdout, " %s\n", name) + } +} diff --git a/cmd/osv-scanner/plugins/command_test.go b/cmd/osv-scanner/plugins/command_test.go new file mode 100644 index 00000000000..1b8f9788937 --- /dev/null +++ b/cmd/osv-scanner/plugins/command_test.go @@ -0,0 +1,58 @@ +package plugins_test + +import ( + "strings" + "testing" + + "github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/testcmd" +) + +func TestCommand_List(t *testing.T) { + t.Parallel() + + stdout, stderr := testcmd.RunAndNormalize(t, testcmd.Case{ + Name: "list", + Args: []string{"", "plugins", "list"}, + Exit: 0, + }) + + if stderr != "" { + t.Fatalf("plugins list wrote to stderr: %q", stderr) + } + + for _, want := range []string{ + "Available plugin presets:", + "extractors: artifact, directory, lockfile, sbom", + "detectors: cis, govulncheck, untested, weakcreds", + "annotators: artifact", + "enrichers: artifact, licenses, transitive, vulns", + "Available exact plugin names:", + "javascript/packagelockjson", + "os/apk", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("plugins list output missing %q\noutput:\n%s", want, stdout) + } + } +} + +func TestCommand_Help(t *testing.T) { + t.Parallel() + + stdout, stderr := testcmd.RunAndNormalize(t, testcmd.Case{ + Name: "help", + Args: []string{"", "plugins", "--help"}, + Exit: 127, + }) + + combined := stdout + "\n" + stderr + for _, want := range []string{ + "osv-scanner plugins", + "lists the available experimental plugin presets and exact plugin names", + "list", + } { + if !strings.Contains(combined, want) { + t.Fatalf("plugins help output missing %q\noutput:\n%s", want, combined) + } + } +} diff --git a/cmd/osv-scanner/plugins/testmain_test.go b/cmd/osv-scanner/plugins/testmain_test.go new file mode 100644 index 00000000000..40f9f06c2c7 --- /dev/null +++ b/cmd/osv-scanner/plugins/testmain_test.go @@ -0,0 +1,21 @@ +package plugins_test + +import ( + "log/slog" + "testing" + + "github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/cmd" + "github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/testcmd" + "github.com/google/osv-scanner/v2/cmd/osv-scanner/plugins" + "github.com/google/osv-scanner/v2/internal/config" + "github.com/google/osv-scanner/v2/internal/testlogger" + "github.com/google/osv-scanner/v2/internal/testutility" +) + +func TestMain(m *testing.M) { + config.OSVScannerConfigName = "osv-scanner-test.toml" + slog.SetDefault(slog.New(testlogger.New())) + testcmd.CommandsUnderTest = []cmd.CommandBuilder{plugins.Command} + m.Run() + testutility.CleanSnapshots(m) +} diff --git a/cmd/osv-scanner/testmain_test.go b/cmd/osv-scanner/testmain_test.go index 920cc9ee74a..45c3c69b9be 100644 --- a/cmd/osv-scanner/testmain_test.go +++ b/cmd/osv-scanner/testmain_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/osv-scanner/v2/cmd/osv-scanner/fix" "github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/cmd" "github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/testcmd" + "github.com/google/osv-scanner/v2/cmd/osv-scanner/plugins" "github.com/google/osv-scanner/v2/cmd/osv-scanner/scan" "github.com/google/osv-scanner/v2/cmd/osv-scanner/update" "github.com/google/osv-scanner/v2/internal/config" @@ -29,6 +30,7 @@ func TestMain(m *testing.M) { testcmd.CommandsUnderTest = []cmd.CommandBuilder{ scan.Command, fix.Command, + plugins.Command, update.Command, } m.Run() diff --git a/internal/scalibrplugin/resolve.go b/internal/scalibrplugin/resolve.go index 13ac8a2dea5..8ab25eb07b3 100644 --- a/internal/scalibrplugin/resolve.go +++ b/internal/scalibrplugin/resolve.go @@ -3,6 +3,7 @@ package scalibrplugin import ( "fmt" + "maps" "slices" cpb "github.com/google/osv-scalibr/binary/proto/config_go_proto" @@ -123,3 +124,63 @@ func filterPluginsMissingRequiredPlugins(pluginStatues map[string]bool, loaded [ return plugins } + +func sortedPresetNames[T any](presets map[string]T) []string { + names := slices.Collect(maps.Keys(presets)) + slices.Sort(names) + + return names +} + +func sortedNestedPluginNames[T any](presets map[string]map[string]T) []string { + seen := map[string]struct{}{} + + for _, preset := range presets { + for name := range preset { + seen[name] = struct{}{} + } + } + + names := slices.Collect(maps.Keys(seen)) + slices.Sort(names) + + return names +} + +func ExtractorPresetNames() []string { + return sortedPresetNames(ExtractorPresets) +} + +func DetectorPresetNames() []string { + return sortedPresetNames(detectorPresets) +} + +func AnnotatorPresetNames() []string { + return sortedPresetNames(annotatorPresets) +} + +func EnricherPresetNames() []string { + return sortedPresetNames(enricherPresets) +} + +func PluginNames() []string { + seen := map[string]struct{}{} + + for _, name := range sortedNestedPluginNames(ExtractorPresets) { + seen[name] = struct{}{} + } + for _, name := range sortedNestedPluginNames(detectorPresets) { + seen[name] = struct{}{} + } + for _, name := range sortedNestedPluginNames(annotatorPresets) { + seen[name] = struct{}{} + } + for _, name := range sortedNestedPluginNames(enricherPresets) { + seen[name] = struct{}{} + } + + names := slices.Collect(maps.Keys(seen)) + slices.Sort(names) + + return names +} diff --git a/internal/scalibrplugin/resolve_test.go b/internal/scalibrplugin/resolve_test.go index 169b9b83150..fe499518f20 100644 --- a/internal/scalibrplugin/resolve_test.go +++ b/internal/scalibrplugin/resolve_test.go @@ -186,6 +186,34 @@ func TestResolve(t *testing.T) { } } +func TestExtractorPresetNames(t *testing.T) { + t.Parallel() + + got := scalibrplugin.ExtractorPresetNames() + want := []string{"artifact", "directory", "lockfile", "sbom"} + + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("ExtractorPresetNames() mismatch (-want +got):\n%s", diff) + } +} + +func TestPluginNames(t *testing.T) { + t.Parallel() + + got := scalibrplugin.PluginNames() + + for _, want := range []string{ + composerlock.Name, + apk.Name, + transitivedependencypomxml.Name, + codeserver.Name, + } { + if !slices.Contains(got, want) { + t.Fatalf("PluginNames() missing %q", want) + } + } +} + func TestResolve_Detectors(t *testing.T) { t.Parallel()