diff --git a/cmd/trivy/main.go b/cmd/trivy/main.go index d07ec31e817e..e2c545975315 100644 --- a/cmd/trivy/main.go +++ b/cmd/trivy/main.go @@ -25,7 +25,7 @@ func run() error { if !plugin.IsPredefined(runAsPlugin) { return xerrors.Errorf("unknown plugin: %s", runAsPlugin) } - if err := plugin.RunWithArgs(context.Background(), runAsPlugin, os.Args[1:]); err != nil { + if err := plugin.RunWithURL(context.Background(), runAsPlugin, plugin.RunOptions{Args: os.Args[1:]}); err != nil { return xerrors.Errorf("plugin error: %w", err) } return nil diff --git a/docs/docs/advanced/plugins.md b/docs/docs/advanced/plugins.md index 390b9cfa565e..dfdfb31d8c0d 100644 --- a/docs/docs/advanced/plugins.md +++ b/docs/docs/advanced/plugins.md @@ -182,8 +182,51 @@ $ trivy myplugin Hello from Trivy demo plugin! ``` +## Plugin Types +Plugins are typically intended to be used as subcommands of Trivy, +but some plugins can be invoked as part of Trivy's built-in commands. +Currently, the following type of plugin is experimentally supported: + +- Output plugins + +### Output Plugins + +!!! warning "EXPERIMENTAL" + This feature might change without preserving backwards compatibility. + +Trivy supports "output plugins" which process Trivy's output, +such as by transforming the output format or sending it elsewhere. +For instance, in the case of image scanning, the output plugin can be called as follows: + +```shell +$ trivy image --format json --output plugin= [--output-plugin-arg ] +``` + +Since scan results are passed to the plugin via standard input, plugins must be capable of handling standard input. + +!!! warning + To avoid Trivy hanging, you need to read all data from `Stdin` before the plugin exits successfully or stops with an error. + +While the example passes JSON to the plugin, other formats like SBOM can also be passed (e.g., `--format cyclonedx`). + +If a plugin requires flags or other arguments, they can be passed using `--output-plugin-arg`. +This is directly forwarded as arguments to the plugin. +For example, `--output plugin=myplugin --output-plugin-arg "--foo --bar=baz"` translates to `myplugin --foo --bar=baz` in execution. + +An example of the output plugin is available [here](https://github.com/aquasecurity/trivy-output-plugin-count). +It can be used as below: + +```shell +# Install the plugin first +$ trivy plugin install github.com/aquasecurity/trivy-output-plugin-count + +# Call the output plugin in image scanning +$ trivy image --format json --output plugin=count --output-plugin-arg "--published-after 2023-10-01" debian:12 +``` + ## Example -https://github.com/aquasecurity/trivy-plugin-kubectl +- https://github.com/aquasecurity/trivy-plugin-kubectl +- https://github.com/aquasecurity/trivy-output-plugin-count [kubectl]: https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/ [helm]: https://helm.sh/docs/topics/plugins/ diff --git a/docs/docs/configuration/reporting.md b/docs/docs/configuration/reporting.md index 17aa6fd973ec..93468222e99b 100644 --- a/docs/docs/configuration/reporting.md +++ b/docs/docs/configuration/reporting.md @@ -1,6 +1,6 @@ # Reporting -## Supported Formats +## Format Trivy supports the following formats: - Table @@ -373,6 +373,33 @@ $ trivy image --format template --template "@/usr/local/share/trivy/templates/ht ### SBOM See [here](../supply-chain/sbom.md) for details. +## Output +Trivy supports the following output destinations: + +- File +- Plugin + +### File +By specifying `--output `, you can output the results to a file. +Here is an example: + +``` +$ trivy image --format json --output result.json debian:12 +``` + +### Plugin +!!! warning "EXPERIMENTAL" + This feature might change without preserving backwards compatibility. + +Plugins capable of receiving Trivy's results via standard input, called "output plugin", can be seamlessly invoked using the `--output` flag. + +``` +$ trivy [--format ] --output plugin= [--output-plugin-arg ] +``` + +This is useful for cases where you want to convert the output into a custom format, or when you want to send the output somewhere. +For more details, please check [here](../advanced/plugins.md#output-plugins). + ## Converting To generate multiple reports, you can generate the JSON report first and convert it to other formats with the `convert` subcommand. diff --git a/docs/docs/references/configuration/cli/trivy_aws.md b/docs/docs/references/configuration/cli/trivy_aws.md index 46a8296dbb80..0218ccb1e987 100644 --- a/docs/docs/references/configuration/cli/trivy_aws.md +++ b/docs/docs/references/configuration/cli/trivy_aws.md @@ -88,6 +88,7 @@ trivy aws [flags] --max-cache-age duration The maximum age of the cloud cache. Cached data will be requeried from the cloud provider if it is older than this. (default 24h0m0s) --misconfig-scanners strings comma-separated list of misconfig scanners to use for misconfiguration scanning (default [azure-arm,cloudformation,dockerfile,helm,kubernetes,terraform,terraformplan]) -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --policy-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0") --policy-namespaces strings Rego namespaces --region string AWS Region to scan diff --git a/docs/docs/references/configuration/cli/trivy_config.md b/docs/docs/references/configuration/cli/trivy_config.md index 19a8983c1784..79d99cad7331 100644 --- a/docs/docs/references/configuration/cli/trivy_config.md +++ b/docs/docs/references/configuration/cli/trivy_config.md @@ -32,6 +32,7 @@ trivy config [flags] DIR --misconfig-scanners strings comma-separated list of misconfig scanners to use for misconfiguration scanning (default [azure-arm,cloudformation,dockerfile,helm,kubernetes,terraform,terraformplan]) --module-dir string specify directory to the wasm modules that will be loaded (default "$HOME/.trivy/modules") -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --password strings password. Comma-separated passwords allowed. TRIVY_PASSWORD should be used for security reasons. --policy-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0") --policy-namespaces strings Rego namespaces diff --git a/docs/docs/references/configuration/cli/trivy_convert.md b/docs/docs/references/configuration/cli/trivy_convert.md index 9ef4cc5583b0..409e1009951d 100644 --- a/docs/docs/references/configuration/cli/trivy_convert.md +++ b/docs/docs/references/configuration/cli/trivy_convert.md @@ -18,19 +18,20 @@ trivy convert [flags] RESULT_JSON ### Options ``` - --compliance string compliance report to generate - --dependency-tree [EXPERIMENTAL] show dependency origin tree of vulnerable packages - --exit-code int specify exit code when any security issues are found - --exit-on-eol int exit with the specified code when the OS reaches end of service/life - -f, --format string format (table,json,template,sarif,cyclonedx,spdx,spdx-json,github,cosign-vuln) (default "table") - -h, --help help for convert - --ignore-policy string specify the Rego file path to evaluate each vulnerability - --ignorefile string specify .trivyignore file (default ".trivyignore") - --list-all-pkgs enabling the option will output all packages regardless of vulnerability - -o, --output string output file name - --report string specify a report format for the output (all,summary) (default "all") - -s, --severity strings severities of security issues to be displayed (UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL) (default [UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL]) - -t, --template string output template + --compliance string compliance report to generate + --dependency-tree [EXPERIMENTAL] show dependency origin tree of vulnerable packages + --exit-code int specify exit code when any security issues are found + --exit-on-eol int exit with the specified code when the OS reaches end of service/life + -f, --format string format (table,json,template,sarif,cyclonedx,spdx,spdx-json,github,cosign-vuln) (default "table") + -h, --help help for convert + --ignore-policy string specify the Rego file path to evaluate each vulnerability + --ignorefile string specify .trivyignore file (default ".trivyignore") + --list-all-pkgs enabling the option will output all packages regardless of vulnerability + -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments + --report string specify a report format for the output (all,summary) (default "all") + -s, --severity strings severities of security issues to be displayed (UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL) (default [UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL]) + -t, --template string output template ``` ### Options inherited from parent commands diff --git a/docs/docs/references/configuration/cli/trivy_filesystem.md b/docs/docs/references/configuration/cli/trivy_filesystem.md index ccc12a1475a8..c09c46f69575 100644 --- a/docs/docs/references/configuration/cli/trivy_filesystem.md +++ b/docs/docs/references/configuration/cli/trivy_filesystem.md @@ -56,6 +56,7 @@ trivy filesystem [flags] PATH --no-progress suppress progress bar --offline-scan do not issue API requests to identify dependencies -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --parallel int number of goroutines enabled for parallel scanning, set 0 to auto-detect parallelism (default 5) --password strings password. Comma-separated passwords allowed. TRIVY_PASSWORD should be used for security reasons. --policy-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0") diff --git a/docs/docs/references/configuration/cli/trivy_image.md b/docs/docs/references/configuration/cli/trivy_image.md index 27264628eac0..c08206a51a5b 100644 --- a/docs/docs/references/configuration/cli/trivy_image.md +++ b/docs/docs/references/configuration/cli/trivy_image.md @@ -74,6 +74,7 @@ trivy image [flags] IMAGE_NAME --no-progress suppress progress bar --offline-scan do not issue API requests to identify dependencies -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --parallel int number of goroutines enabled for parallel scanning, set 0 to auto-detect parallelism (default 5) --password strings password. Comma-separated passwords allowed. TRIVY_PASSWORD should be used for security reasons. --platform string set platform in the form os/arch if image is multi-platform capable diff --git a/docs/docs/references/configuration/cli/trivy_kubernetes.md b/docs/docs/references/configuration/cli/trivy_kubernetes.md index 35f6fe6a231a..9599ca493d84 100644 --- a/docs/docs/references/configuration/cli/trivy_kubernetes.md +++ b/docs/docs/references/configuration/cli/trivy_kubernetes.md @@ -67,6 +67,7 @@ trivy kubernetes [flags] { cluster | all | specific resources like kubectl. eg: --node-collector-namespace string specify the namespace in which the node-collector job should be deployed (default "trivy-temp") --offline-scan do not issue API requests to identify dependencies -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --parallel int number of goroutines enabled for parallel scanning, set 0 to auto-detect parallelism (default 5) --password strings password. Comma-separated passwords allowed. TRIVY_PASSWORD should be used for security reasons. --policy-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0") diff --git a/docs/docs/references/configuration/cli/trivy_repository.md b/docs/docs/references/configuration/cli/trivy_repository.md index 339064883224..4f2a9f65d30b 100644 --- a/docs/docs/references/configuration/cli/trivy_repository.md +++ b/docs/docs/references/configuration/cli/trivy_repository.md @@ -56,6 +56,7 @@ trivy repository [flags] (REPO_PATH | REPO_URL) --no-progress suppress progress bar --offline-scan do not issue API requests to identify dependencies -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --parallel int number of goroutines enabled for parallel scanning, set 0 to auto-detect parallelism (default 5) --password strings password. Comma-separated passwords allowed. TRIVY_PASSWORD should be used for security reasons. --policy-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0") diff --git a/docs/docs/references/configuration/cli/trivy_rootfs.md b/docs/docs/references/configuration/cli/trivy_rootfs.md index 5d5f88451afd..aaa120285021 100644 --- a/docs/docs/references/configuration/cli/trivy_rootfs.md +++ b/docs/docs/references/configuration/cli/trivy_rootfs.md @@ -58,6 +58,7 @@ trivy rootfs [flags] ROOTDIR --no-progress suppress progress bar --offline-scan do not issue API requests to identify dependencies -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --parallel int number of goroutines enabled for parallel scanning, set 0 to auto-detect parallelism (default 5) --password strings password. Comma-separated passwords allowed. TRIVY_PASSWORD should be used for security reasons. --policy-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0") diff --git a/docs/docs/references/configuration/cli/trivy_sbom.md b/docs/docs/references/configuration/cli/trivy_sbom.md index 9f899ac977cb..b0fd202863b0 100644 --- a/docs/docs/references/configuration/cli/trivy_sbom.md +++ b/docs/docs/references/configuration/cli/trivy_sbom.md @@ -42,6 +42,7 @@ trivy sbom [flags] SBOM_PATH --no-progress suppress progress bar --offline-scan do not issue API requests to identify dependencies -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --redis-ca string redis ca file location, if using redis as cache backend --redis-cert string redis certificate file location, if using redis as cache backend --redis-key string redis key file location, if using redis as cache backend diff --git a/docs/docs/references/configuration/cli/trivy_vm.md b/docs/docs/references/configuration/cli/trivy_vm.md index 0fc813ade0ca..2b004b6992e9 100644 --- a/docs/docs/references/configuration/cli/trivy_vm.md +++ b/docs/docs/references/configuration/cli/trivy_vm.md @@ -52,6 +52,7 @@ trivy vm [flags] VM_IMAGE --no-progress suppress progress bar --offline-scan do not issue API requests to identify dependencies -o, --output string output file name + --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --parallel int number of goroutines enabled for parallel scanning, set 0 to auto-detect parallelism (default 5) --policy-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0") --redis-ca string redis ca file location, if using redis as cache backend diff --git a/go.mod b/go.mod index 6e27ea8c9462..2251df877847 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,7 @@ require ( github.com/masahiro331/go-mvn-version v0.0.0-20210429150710-d3157d602a08 github.com/masahiro331/go-vmdk-parser v0.0.0-20221225061455-612096e4bbbd github.com/masahiro331/go-xfs-filesystem v0.0.0-20230608043311-a335f4599b70 + github.com/mattn/go-shellwords v1.0.12 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/mitchellh/mapstructure v1.5.0 github.com/moby/buildkit v0.11.6 diff --git a/go.sum b/go.sum index 541b28e875b8..25c06c78abbd 100644 --- a/go.sum +++ b/go.sum @@ -1326,6 +1326,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= diff --git a/pkg/cloud/aws/commands/run.go b/pkg/cloud/aws/commands/run.go index 0562dbf72e21..a4541e9f0544 100644 --- a/pkg/cloud/aws/commands/run.go +++ b/pkg/cloud/aws/commands/run.go @@ -129,7 +129,6 @@ func filterServices(opt *flag.Options) error { } func Run(ctx context.Context, opt flag.Options) error { - ctx, cancel := context.WithTimeout(ctx, opt.GlobalOptions.Timeout) defer cancel() @@ -168,7 +167,7 @@ func Run(ctx context.Context, opt flag.Options) error { } r := report.New(cloud.ProviderAWS, opt.Account, opt.Region, res, opt.Services) - if err := report.Write(r, opt, cached); err != nil { + if err := report.Write(ctx, r, opt, cached); err != nil { return xerrors.Errorf("unable to write results: %w", err) } diff --git a/pkg/cloud/report/report.go b/pkg/cloud/report/report.go index 6c7b1ac6d874..8c4fa3861b8e 100644 --- a/pkg/cloud/report/report.go +++ b/pkg/cloud/report/report.go @@ -59,8 +59,8 @@ func (r *Report) Failed() bool { } // Write writes the results in the give format -func Write(rep *Report, opt flag.Options, fromCache bool) error { - output, cleanup, err := opt.OutputWriter() +func Write(ctx context.Context, rep *Report, opt flag.Options, fromCache bool) error { + output, cleanup, err := opt.OutputWriter(ctx) if err != nil { return xerrors.Errorf("failed to create output file: %w", err) } @@ -72,8 +72,6 @@ func Write(rep *Report, opt flag.Options, fromCache bool) error { var filtered []types.Result - ctx := context.Background() - // filter results for _, resultsAtTime := range rep.Results { for _, res := range resultsAtTime.Results { @@ -137,7 +135,7 @@ func Write(rep *Report, opt flag.Options, fromCache bool) error { return nil default: - return pkgReport.Write(base, opt) + return pkgReport.Write(ctx, base, opt) } } diff --git a/pkg/cloud/report/resource_test.go b/pkg/cloud/report/resource_test.go index cb17c2658d57..3f909b8d3b3f 100644 --- a/pkg/cloud/report/resource_test.go +++ b/pkg/cloud/report/resource_test.go @@ -2,6 +2,7 @@ package report import ( "bytes" + "context" "testing" "github.com/stretchr/testify/assert" @@ -111,7 +112,7 @@ No problems detected. output := bytes.NewBuffer(nil) tt.options.SetOutputWriter(output) - require.NoError(t, Write(report, tt.options, tt.fromCache)) + require.NoError(t, Write(context.Background(), report, tt.options, tt.fromCache)) assert.Equal(t, "AWS", report.Provider) assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID) diff --git a/pkg/cloud/report/result_test.go b/pkg/cloud/report/result_test.go index f0ef85d4d564..6afc67305c4f 100644 --- a/pkg/cloud/report/result_test.go +++ b/pkg/cloud/report/result_test.go @@ -2,6 +2,7 @@ package report import ( "bytes" + "context" "strings" "testing" @@ -70,7 +71,7 @@ See https://avd.aquasec.com/misconfig/avd-aws-9999 output := bytes.NewBuffer(nil) tt.options.SetOutputWriter(output) - require.NoError(t, Write(report, tt.options, tt.fromCache)) + require.NoError(t, Write(context.Background(), report, tt.options, tt.fromCache)) assert.Equal(t, "AWS", report.Provider) assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID) diff --git a/pkg/cloud/report/service_test.go b/pkg/cloud/report/service_test.go index b60e6f37c7ce..507f3ff31466 100644 --- a/pkg/cloud/report/service_test.go +++ b/pkg/cloud/report/service_test.go @@ -2,6 +2,7 @@ package report import ( "bytes" + "context" "github.com/aquasecurity/trivy/pkg/clock" "testing" "time" @@ -322,7 +323,7 @@ Scan Overview for AWS Account output := bytes.NewBuffer(nil) tt.options.SetOutputWriter(output) - require.NoError(t, Write(report, tt.options, tt.fromCache)) + require.NoError(t, Write(context.Background(), report, tt.options, tt.fromCache)) assert.Equal(t, "AWS", report.Provider) assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID) diff --git a/pkg/commands/app.go b/pkg/commands/app.go index fe39fb57925c..dac78354075b 100644 --- a/pkg/commands/app.go +++ b/pkg/commands/app.go @@ -124,7 +124,7 @@ func loadPluginCommands() []*cobra.Command { Short: p.Usage, GroupID: groupPlugin, RunE: func(cmd *cobra.Command, args []string) error { - if err = p.Run(cmd.Context(), args); err != nil { + if err = p.Run(cmd.Context(), plugin.RunOptions{Args: args}); err != nil { return xerrors.Errorf("plugin error: %w", err) } return nil @@ -773,7 +773,7 @@ func NewPluginCommand() *cobra.Command { Short: "Run a plugin on the fly", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return plugin.RunWithArgs(cmd.Context(), args[0], args[1:]) + return plugin.RunWithURL(cmd.Context(), args[0], plugin.RunOptions{Args: args[1:]}) }, }, &cobra.Command{ diff --git a/pkg/commands/artifact/run.go b/pkg/commands/artifact/run.go index 2370cc6c1de9..5c32c6d2b250 100644 --- a/pkg/commands/artifact/run.go +++ b/pkg/commands/artifact/run.go @@ -91,7 +91,7 @@ type Runner interface { // Filter filter a report Filter(ctx context.Context, opts flag.Options, report types.Report) (types.Report, error) // Report a writes a report - Report(opts flag.Options, report types.Report) error + Report(ctx context.Context, opts flag.Options, report types.Report) error // Close closes runner Close(ctx context.Context) error } @@ -280,8 +280,8 @@ func (r *runner) Filter(ctx context.Context, opts flag.Options, report types.Rep return report, nil } -func (r *runner) Report(opts flag.Options, report types.Report) error { - if err := pkgReport.Write(report, opts); err != nil { +func (r *runner) Report(ctx context.Context, opts flag.Options, report types.Report) error { + if err := pkgReport.Write(ctx, report, opts); err != nil { return xerrors.Errorf("unable to write results: %w", err) } @@ -451,7 +451,7 @@ func Run(ctx context.Context, opts flag.Options, targetKind TargetKind) (err err return xerrors.Errorf("filter error: %w", err) } - if err = r.Report(opts, report); err != nil { + if err = r.Report(ctx, opts, report); err != nil { return xerrors.Errorf("report error: %w", err) } diff --git a/pkg/commands/convert/run.go b/pkg/commands/convert/run.go index 490864a14bdd..9045e54bfa3d 100644 --- a/pkg/commands/convert/run.go +++ b/pkg/commands/convert/run.go @@ -16,6 +16,9 @@ import ( ) func Run(ctx context.Context, opts flag.Options) (err error) { + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + f, err := os.Open(opts.Target) if err != nil { return xerrors.Errorf("file open error: %w", err) @@ -37,7 +40,7 @@ func Run(ctx context.Context, opts flag.Options) (err error) { } log.Logger.Debug("Writing report to output...") - if err = report.Write(r, opts); err != nil { + if err = report.Write(ctx, r, opts); err != nil { return xerrors.Errorf("unable to write results: %w", err) } diff --git a/pkg/flag/options.go b/pkg/flag/options.go index b231705f50da..fb3d69eaa396 100644 --- a/pkg/flag/options.go +++ b/pkg/flag/options.go @@ -1,6 +1,7 @@ package flag import ( + "context" "fmt" "io" "os" @@ -17,6 +18,7 @@ import ( "github.com/aquasecurity/trivy/pkg/fanal/analyzer" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/log" + "github.com/aquasecurity/trivy/pkg/plugin" "github.com/aquasecurity/trivy/pkg/result" "github.com/aquasecurity/trivy/pkg/types" "github.com/aquasecurity/trivy/pkg/version" @@ -173,19 +175,46 @@ func (o *Options) SetOutputWriter(w io.Writer) { // OutputWriter returns an output writer. // If the output file is not specified, it returns os.Stdout. -func (o *Options) OutputWriter() (io.Writer, func(), error) { - if o.outputWriter != nil { - return o.outputWriter, func() {}, nil +func (o *Options) OutputWriter(ctx context.Context) (io.Writer, func() error, error) { + cleanup := func() error { return nil } + switch { + case o.outputWriter != nil: + return o.outputWriter, cleanup, nil + case o.Output == "": + return os.Stdout, cleanup, nil + case strings.HasPrefix(o.Output, "plugin="): + return o.outputPluginWriter(ctx) } - if o.Output != "" { - f, err := os.Create(o.Output) - if err != nil { - return nil, nil, xerrors.Errorf("failed to create output file: %w", err) + f, err := os.Create(o.Output) + if err != nil { + return nil, nil, xerrors.Errorf("failed to create output file: %w", err) + } + return f, f.Close, nil +} + +func (o *Options) outputPluginWriter(ctx context.Context) (io.Writer, func() error, error) { + pluginName := strings.TrimPrefix(o.Output, "plugin=") + + pr, pw := io.Pipe() + wait, err := plugin.Start(ctx, pluginName, plugin.RunOptions{ + Args: o.OutputPluginArgs, + Stdin: pr, + }) + if err != nil { + return nil, nil, xerrors.Errorf("plugin start: %w", err) + } + + cleanup := func() error { + if err = pw.Close(); err != nil { + return xerrors.Errorf("failed to close pipe: %w", err) } - return f, func() { _ = f.Close() }, nil + if err = wait(); err != nil { + return xerrors.Errorf("plugin error: %w", err) + } + return nil } - return os.Stdout, func() {}, nil + return pw, cleanup, nil } func addFlag(cmd *cobra.Command, flag *Flag) { diff --git a/pkg/flag/report_flags.go b/pkg/flag/report_flags.go index 50304f5d7883..54554be3d126 100644 --- a/pkg/flag/report_flags.go +++ b/pkg/flag/report_flags.go @@ -3,6 +3,7 @@ package flag import ( "strings" + "github.com/mattn/go-shellwords" "github.com/samber/lo" "golang.org/x/exp/slices" "golang.org/x/xerrors" @@ -86,6 +87,12 @@ var ( Default: "", Usage: "output file name", } + OutputPluginArgFlag = Flag{ + Name: "output-plugin-arg", + ConfigName: "output-plugin-arg", + Default: "", + Usage: "[EXPERIMENTAL] output plugin arguments", + } SeverityFlag = Flag{ Name: "severity", ConfigName: "severity", @@ -105,49 +112,52 @@ var ( // ReportFlagGroup composes common printer flag structs // used for commands requiring reporting logic. type ReportFlagGroup struct { - Format *Flag - ReportFormat *Flag - Template *Flag - DependencyTree *Flag - ListAllPkgs *Flag - IgnoreFile *Flag - IgnorePolicy *Flag - ExitCode *Flag - ExitOnEOL *Flag - Output *Flag - Severity *Flag - Compliance *Flag + Format *Flag + ReportFormat *Flag + Template *Flag + DependencyTree *Flag + ListAllPkgs *Flag + IgnoreFile *Flag + IgnorePolicy *Flag + ExitCode *Flag + ExitOnEOL *Flag + Output *Flag + OutputPluginArg *Flag + Severity *Flag + Compliance *Flag } type ReportOptions struct { - Format types.Format - ReportFormat string - Template string - DependencyTree bool - ListAllPkgs bool - IgnoreFile string - ExitCode int - ExitOnEOL int - IgnorePolicy string - Output string - Severities []dbTypes.Severity - Compliance spec.ComplianceSpec + Format types.Format + ReportFormat string + Template string + DependencyTree bool + ListAllPkgs bool + IgnoreFile string + ExitCode int + ExitOnEOL int + IgnorePolicy string + Output string + OutputPluginArgs []string + Severities []dbTypes.Severity + Compliance spec.ComplianceSpec } func NewReportFlagGroup() *ReportFlagGroup { return &ReportFlagGroup{ - Format: &FormatFlag, - ReportFormat: &ReportFormatFlag, - Template: &TemplateFlag, - DependencyTree: &DependencyTreeFlag, - ListAllPkgs: &ListAllPkgsFlag, - IgnoreFile: &IgnoreFileFlag, - IgnorePolicy: &IgnorePolicyFlag, - ExitCode: &ExitCodeFlag, - ExitOnEOL: &ExitOnEOLFlag, - Output: &OutputFlag, - Severity: &SeverityFlag, - Compliance: &ComplianceFlag, + Format: &FormatFlag, + ReportFormat: &ReportFormatFlag, + Template: &TemplateFlag, + DependencyTree: &DependencyTreeFlag, + ListAllPkgs: &ListAllPkgsFlag, + IgnoreFile: &IgnoreFileFlag, + IgnorePolicy: &IgnorePolicyFlag, + ExitCode: &ExitCodeFlag, + ExitOnEOL: &ExitOnEOLFlag, + Output: &OutputFlag, + OutputPluginArg: &OutputPluginArgFlag, + Severity: &SeverityFlag, + Compliance: &ComplianceFlag, } } @@ -167,6 +177,7 @@ func (f *ReportFlagGroup) Flags() []*Flag { f.ExitCode, f.ExitOnEOL, f.Output, + f.OutputPluginArg, f.Severity, f.Compliance, } @@ -216,19 +227,28 @@ func (f *ReportFlagGroup) ToOptions() (ReportOptions, error) { return ReportOptions{}, xerrors.Errorf("unable to load compliance spec: %w", err) } + var outputPluginArgs []string + if arg := getString(f.OutputPluginArg); arg != "" { + outputPluginArgs, err = shellwords.Parse(arg) + if err != nil { + return ReportOptions{}, xerrors.Errorf("unable to parse output plugin argument: %w", err) + } + } + return ReportOptions{ - Format: format, - ReportFormat: getString(f.ReportFormat), - Template: template, - DependencyTree: dependencyTree, - ListAllPkgs: listAllPkgs, - IgnoreFile: getString(f.IgnoreFile), - ExitCode: getInt(f.ExitCode), - ExitOnEOL: getInt(f.ExitOnEOL), - IgnorePolicy: getString(f.IgnorePolicy), - Output: getString(f.Output), - Severities: toSeverity(getStringSlice(f.Severity)), - Compliance: cs, + Format: format, + ReportFormat: getString(f.ReportFormat), + Template: template, + DependencyTree: dependencyTree, + ListAllPkgs: listAllPkgs, + IgnoreFile: getString(f.IgnoreFile), + ExitCode: getInt(f.ExitCode), + ExitOnEOL: getInt(f.ExitOnEOL), + IgnorePolicy: getString(f.IgnorePolicy), + Output: getString(f.Output), + OutputPluginArgs: outputPluginArgs, + Severities: toSeverity(getStringSlice(f.Severity)), + Compliance: cs, }, nil } diff --git a/pkg/flag/report_flags_test.go b/pkg/flag/report_flags_test.go index 9155addbbe64..c2e92fc5fcbd 100644 --- a/pkg/flag/report_flags_test.go +++ b/pkg/flag/report_flags_test.go @@ -18,20 +18,20 @@ import ( func TestReportFlagGroup_ToOptions(t *testing.T) { type fields struct { - format types.Format - template string - dependencyTree bool - listAllPkgs bool - ignoreUnfixed bool - ignoreFile string - exitCode int - exitOnEOSL bool - ignorePolicy string - output string - severities string - compliane string - - debug bool + format types.Format + template string + dependencyTree bool + listAllPkgs bool + ignoreUnfixed bool + ignoreFile string + exitCode int + exitOnEOSL bool + ignorePolicy string + output string + outputPluginArgs string + severities string + compliance string + debug bool } tests := []struct { name string @@ -63,8 +63,7 @@ func TestReportFlagGroup_ToOptions(t *testing.T) { severities: "CRITICAL", format: "cyclonedx", listAllPkgs: false, - - debug: true, + debug: true, }, wantLogs: []string{ `["cyclonedx" "spdx" "spdx-json" "github"] automatically enables '--list-all-pkgs'.`, @@ -138,10 +137,26 @@ func TestReportFlagGroup_ToOptions(t *testing.T) { ListAllPkgs: true, }, }, + { + name: "happy path with output plugin args", + fields: fields{ + output: "plugin=count", + outputPluginArgs: "--publish-after 2023-10-01 --publish-before 2023-10-02", + }, + want: flag.ReportOptions{ + Output: "plugin=count", + OutputPluginArgs: []string{ + "--publish-after", + "2023-10-01", + "--publish-before", + "2023-10-02", + }, + }, + }, { name: "happy path with compliance", fields: fields{ - compliane: "@testdata/example-spec.yaml", + compliance: "@testdata/example-spec.yaml", severities: dbTypes.SeverityLow.String(), }, want: flag.ReportOptions{ @@ -187,22 +202,24 @@ func TestReportFlagGroup_ToOptions(t *testing.T) { viper.Set(flag.ExitCodeFlag.ConfigName, tt.fields.exitCode) viper.Set(flag.ExitOnEOLFlag.ConfigName, tt.fields.exitOnEOSL) viper.Set(flag.OutputFlag.ConfigName, tt.fields.output) + viper.Set(flag.OutputPluginArgFlag.ConfigName, tt.fields.outputPluginArgs) viper.Set(flag.SeverityFlag.ConfigName, tt.fields.severities) - viper.Set(flag.ComplianceFlag.ConfigName, tt.fields.compliane) + viper.Set(flag.ComplianceFlag.ConfigName, tt.fields.compliance) // Assert options f := &flag.ReportFlagGroup{ - Format: &flag.FormatFlag, - Template: &flag.TemplateFlag, - DependencyTree: &flag.DependencyTreeFlag, - ListAllPkgs: &flag.ListAllPkgsFlag, - IgnoreFile: &flag.IgnoreFileFlag, - IgnorePolicy: &flag.IgnorePolicyFlag, - ExitCode: &flag.ExitCodeFlag, - ExitOnEOL: &flag.ExitOnEOLFlag, - Output: &flag.OutputFlag, - Severity: &flag.SeverityFlag, - Compliance: &flag.ComplianceFlag, + Format: &flag.FormatFlag, + Template: &flag.TemplateFlag, + DependencyTree: &flag.DependencyTreeFlag, + ListAllPkgs: &flag.ListAllPkgsFlag, + IgnoreFile: &flag.IgnoreFileFlag, + IgnorePolicy: &flag.IgnorePolicyFlag, + ExitCode: &flag.ExitCodeFlag, + ExitOnEOL: &flag.ExitOnEOLFlag, + Output: &flag.OutputFlag, + OutputPluginArg: &flag.OutputPluginArgFlag, + Severity: &flag.SeverityFlag, + Compliance: &flag.ComplianceFlag, } got, err := f.ToOptions() diff --git a/pkg/k8s/commands/run.go b/pkg/k8s/commands/run.go index 9207363342c6..ec3e61385c65 100644 --- a/pkg/k8s/commands/run.go +++ b/pkg/k8s/commands/run.go @@ -101,7 +101,7 @@ func (r *runner) run(ctx context.Context, artifacts []*k8sArtifacts.Artifact) er return xerrors.Errorf("k8s scan error: %w", err) } - output, cleanup, err := r.flagOpts.OutputWriter() + output, cleanup, err := r.flagOpts.OutputWriter(ctx) if err != nil { return xerrors.Errorf("failed to create output file: %w", err) } diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 34eeb7eb3e1d..f96f02a80c00 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -3,6 +3,7 @@ package plugin import ( "context" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -57,21 +58,55 @@ type Selector struct { Arch string } -// Run runs the plugin -func (p Plugin) Run(ctx context.Context, args []string) error { +type RunOptions struct { + Args []string + Stdin io.Reader +} + +func (p Plugin) Cmd(ctx context.Context, opts RunOptions) (*exec.Cmd, error) { platform, err := p.selectPlatform() if err != nil { - return xerrors.Errorf("platform selection error: %w", err) + return nil, xerrors.Errorf("platform selection error: %w", err) } execFile := filepath.Join(dir(), p.Name, platform.Bin) - cmd := exec.CommandContext(ctx, execFile, args...) + cmd := exec.CommandContext(ctx, execFile, opts.Args...) cmd.Stdin = os.Stdin + if opts.Stdin != nil { + cmd.Stdin = opts.Stdin + } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = os.Environ() + return cmd, nil +} + +type Wait func() error + +// Start starts the plugin +// +// After a successful call to Start the Wait method must be called. +func (p Plugin) Start(ctx context.Context, opts RunOptions) (Wait, error) { + cmd, err := p.Cmd(ctx, opts) + if err != nil { + return nil, xerrors.Errorf("cmd: %w", err) + } + + if err = cmd.Start(); err != nil { + return nil, xerrors.Errorf("plugin start: %w", err) + } + return cmd.Wait, nil +} + +// Run runs the plugin +func (p Plugin) Run(ctx context.Context, opts RunOptions) error { + cmd, err := p.Cmd(ctx, opts) + if err != nil { + return xerrors.Errorf("cmd: %w", err) + } + // If an error is found during the execution of the plugin, figure // out if the error was from not being able to execute the plugin or // an error set by the plugin itself. @@ -79,10 +114,8 @@ func (p Plugin) Run(ctx context.Context, args []string) error { if _, ok := err.(*exec.ExitError); !ok { return xerrors.Errorf("exit: %w", err) } - return xerrors.Errorf("plugin exec: %w", err) } - return nil } @@ -186,18 +219,9 @@ func Uninstall(name string) error { // Information gets the information about an installed plugin func Information(name string) (string, error) { - pluginDir := filepath.Join(dir(), name) - - if _, err := os.Stat(pluginDir); err != nil { - if os.IsNotExist(err) { - return "", xerrors.Errorf("could not find a plugin called '%s', did you install it?", name) - } - return "", xerrors.Errorf("stat error: %w", err) - } - - plugin, err := loadMetadata(pluginDir) + plugin, err := load(name) if err != nil { - return "", xerrors.Errorf("unable to load metadata: %w", err) + return "", xerrors.Errorf("plugin load error: %w", err) } return fmt.Sprintf(` @@ -230,19 +254,11 @@ func List() (string, error) { // Update updates an existing plugin func Update(name string) error { - pluginDir := filepath.Join(dir(), name) - - if _, err := os.Stat(pluginDir); err != nil { - if os.IsNotExist(err) { - return xerrors.Errorf("could not find a plugin called '%s' to update: %w", name, err) - } - return err - } - - plugin, err := loadMetadata(pluginDir) + plugin, err := load(name) if err != nil { - return err + return xerrors.Errorf("plugin load error: %w", err) } + log.Logger.Infof("Updating plugin '%s'", name) updated, err := Install(nil, plugin.Repository, true) if err != nil { @@ -280,15 +296,29 @@ func LoadAll() ([]Plugin, error) { return plugins, nil } -// RunWithArgs runs the plugin with arguments -func RunWithArgs(ctx context.Context, url string, args []string) error { - pl, err := Install(ctx, url, false) +// Start starts the plugin +func Start(ctx context.Context, name string, opts RunOptions) (Wait, error) { + plugin, err := load(name) + if err != nil { + return nil, xerrors.Errorf("plugin load error: %w", err) + } + + wait, err := plugin.Start(ctx, opts) + if err != nil { + return nil, xerrors.Errorf("unable to run %s plugin: %w", plugin.Name, err) + } + return wait, nil +} + +// RunWithURL runs the plugin with URL +func RunWithURL(ctx context.Context, url string, opts RunOptions) error { + plugin, err := Install(ctx, url, false) if err != nil { return xerrors.Errorf("plugin install error: %w", err) } - if err = pl.Run(ctx, args); err != nil { - return xerrors.Errorf("unable to run %s plugin: %w", pl.Name, err) + if err = plugin.Run(ctx, opts); err != nil { + return xerrors.Errorf("unable to run %s plugin: %w", plugin.Name, err) } return nil } @@ -298,6 +328,23 @@ func IsPredefined(name string) bool { return ok } +func load(name string) (Plugin, error) { + pluginDir := filepath.Join(dir(), name) + if _, err := os.Stat(pluginDir); err != nil { + if os.IsNotExist(err) { + return Plugin{}, xerrors.Errorf("could not find a plugin called '%s', did you install it?", name) + } + return Plugin{}, xerrors.Errorf("plugin stat error: %w", err) + } + + plugin, err := loadMetadata(pluginDir) + if err != nil { + return Plugin{}, xerrors.Errorf("unable to load plugin metadata: %w", err) + } + + return plugin, nil +} + func loadMetadata(dir string) (Plugin, error) { filePath := filepath.Join(dir, configFile) f, err := os.Open(filePath) diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go index 6839d7eb7c9d..f9ee7aac2b89 100644 --- a/pkg/plugin/plugin_test.go +++ b/pkg/plugin/plugin_test.go @@ -29,13 +29,10 @@ func TestPlugin_Run(t *testing.T) { GOOS string GOARCH string } - type args struct { - args []string - } tests := []struct { name string fields fields - args args + opts plugin.RunOptions wantErr string }{ { @@ -162,7 +159,7 @@ func TestPlugin_Run(t *testing.T) { GOARCH: tt.fields.GOARCH, } - err := p.Run(context.Background(), tt.args.args) + err := p.Run(context.Background(), tt.opts) if tt.wantErr != "" { require.NotNil(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -338,7 +335,7 @@ description: A simple test plugin` // Get Information for unknown plugin info, err = plugin.Information("unknown") require.Error(t, err) - assert.Equal(t, "could not find a plugin called 'unknown', did you install it?", err.Error()) + assert.ErrorContains(t, err, "could not find a plugin called 'unknown', did you install it?") } func TestLoadAll1(t *testing.T) { diff --git a/pkg/report/writer.go b/pkg/report/writer.go index 648a9372f534..e53cad0e0463 100644 --- a/pkg/report/writer.go +++ b/pkg/report/writer.go @@ -1,6 +1,8 @@ package report import ( + "context" + "errors" "io" "strings" "sync" @@ -24,12 +26,16 @@ const ( ) // Write writes the result to output, format as passed in argument -func Write(report types.Report, option flag.Options) error { - output, cleanup, err := option.OutputWriter() +func Write(ctx context.Context, report types.Report, option flag.Options) (err error) { + output, cleanup, err := option.OutputWriter(ctx) if err != nil { return xerrors.Errorf("failed to create a file: %w", err) } - defer cleanup() + defer func() { + if cerr := cleanup(); cerr != nil { + err = errors.Join(err, cerr) + } + }() // Compliance report if option.Compliance.Spec.ID != "" { @@ -91,9 +97,10 @@ func Write(report types.Report, option flag.Options) error { return xerrors.Errorf("unknown format: %v", option.Format) } - if err := writer.Write(report); err != nil { + if err = writer.Write(report); err != nil { return xerrors.Errorf("failed to write results: %w", err) } + return nil }