feat(report): output plugin (#4863)

Signed-off-by: knqyf263 <knqyf263@gmail.com>
Co-authored-by: DmitriyLewen <dmitriy.lewen@smartforce.io>
This commit is contained in:
Teppei Fukuda
2023-12-04 15:04:43 +04:00
committed by GitHub
parent 70078b9c0e
commit 99c04c4383
30 changed files with 367 additions and 164 deletions

View File

@@ -25,7 +25,7 @@ func run() error {
if !plugin.IsPredefined(runAsPlugin) { if !plugin.IsPredefined(runAsPlugin) {
return xerrors.Errorf("unknown plugin: %s", 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 xerrors.Errorf("plugin error: %w", err)
} }
return nil return nil

View File

@@ -182,8 +182,51 @@ $ trivy myplugin
Hello from Trivy demo plugin! 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=<plugin_name> [--output-plugin-arg <plugin_flags>] <image_name>
```
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 ## 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/ [kubectl]: https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/
[helm]: https://helm.sh/docs/topics/plugins/ [helm]: https://helm.sh/docs/topics/plugins/

View File

@@ -1,6 +1,6 @@
# Reporting # Reporting
## Supported Formats ## Format
Trivy supports the following formats: Trivy supports the following formats:
- Table - Table
@@ -373,6 +373,33 @@ $ trivy image --format template --template "@/usr/local/share/trivy/templates/ht
### SBOM ### SBOM
See [here](../supply-chain/sbom.md) for details. See [here](../supply-chain/sbom.md) for details.
## Output
Trivy supports the following output destinations:
- File
- Plugin
### File
By specifying `--output <file_path>`, 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 <target> [--format <format>] --output plugin=<plugin_name> [--output-plugin-arg <plugin_flags>] <target_name>
```
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 ## Converting
To generate multiple reports, you can generate the JSON report first and convert it to other formats with the `convert` subcommand. To generate multiple reports, you can generate the JSON report first and convert it to other formats with the `convert` subcommand.

View File

@@ -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) --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]) --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 -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-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0")
--policy-namespaces strings Rego namespaces --policy-namespaces strings Rego namespaces
--region string AWS Region to scan --region string AWS Region to scan

View File

@@ -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]) --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") --module-dir string specify directory to the wasm modules that will be loaded (default "$HOME/.trivy/modules")
-o, --output string output file name -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. --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-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0")
--policy-namespaces strings Rego namespaces --policy-namespaces strings Rego namespaces

View File

@@ -28,6 +28,7 @@ trivy convert [flags] RESULT_JSON
--ignorefile string specify .trivyignore file (default ".trivyignore") --ignorefile string specify .trivyignore file (default ".trivyignore")
--list-all-pkgs enabling the option will output all packages regardless of vulnerability --list-all-pkgs enabling the option will output all packages regardless of vulnerability
-o, --output string output file name -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") --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]) -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 -t, --template string output template

View File

@@ -56,6 +56,7 @@ trivy filesystem [flags] PATH
--no-progress suppress progress bar --no-progress suppress progress bar
--offline-scan do not issue API requests to identify dependencies --offline-scan do not issue API requests to identify dependencies
-o, --output string output file name -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) --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. --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-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0")

View File

@@ -74,6 +74,7 @@ trivy image [flags] IMAGE_NAME
--no-progress suppress progress bar --no-progress suppress progress bar
--offline-scan do not issue API requests to identify dependencies --offline-scan do not issue API requests to identify dependencies
-o, --output string output file name -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) --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. --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 --platform string set platform in the form os/arch if image is multi-platform capable

View File

@@ -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") --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 --offline-scan do not issue API requests to identify dependencies
-o, --output string output file name -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) --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. --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-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0")

View File

@@ -56,6 +56,7 @@ trivy repository [flags] (REPO_PATH | REPO_URL)
--no-progress suppress progress bar --no-progress suppress progress bar
--offline-scan do not issue API requests to identify dependencies --offline-scan do not issue API requests to identify dependencies
-o, --output string output file name -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) --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. --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-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0")

View File

@@ -58,6 +58,7 @@ trivy rootfs [flags] ROOTDIR
--no-progress suppress progress bar --no-progress suppress progress bar
--offline-scan do not issue API requests to identify dependencies --offline-scan do not issue API requests to identify dependencies
-o, --output string output file name -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) --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. --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-bundle-repository string OCI registry URL to retrieve policy bundle from (default "ghcr.io/aquasecurity/trivy-policies:0")

View File

@@ -42,6 +42,7 @@ trivy sbom [flags] SBOM_PATH
--no-progress suppress progress bar --no-progress suppress progress bar
--offline-scan do not issue API requests to identify dependencies --offline-scan do not issue API requests to identify dependencies
-o, --output string output file name -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-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-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 --redis-key string redis key file location, if using redis as cache backend

View File

@@ -52,6 +52,7 @@ trivy vm [flags] VM_IMAGE
--no-progress suppress progress bar --no-progress suppress progress bar
--offline-scan do not issue API requests to identify dependencies --offline-scan do not issue API requests to identify dependencies
-o, --output string output file name -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) --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") --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 --redis-ca string redis ca file location, if using redis as cache backend

1
go.mod
View File

@@ -72,6 +72,7 @@ require (
github.com/masahiro331/go-mvn-version v0.0.0-20210429150710-d3157d602a08 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-vmdk-parser v0.0.0-20221225061455-612096e4bbbd
github.com/masahiro331/go-xfs-filesystem v0.0.0-20230608043311-a335f4599b70 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/hashstructure/v2 v2.0.2
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/moby/buildkit v0.11.6 github.com/moby/buildkit v0.11.6

2
go.sum
View File

@@ -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 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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.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.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 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=

View File

@@ -129,7 +129,6 @@ func filterServices(opt *flag.Options) error {
} }
func Run(ctx context.Context, opt flag.Options) error { func Run(ctx context.Context, opt flag.Options) error {
ctx, cancel := context.WithTimeout(ctx, opt.GlobalOptions.Timeout) ctx, cancel := context.WithTimeout(ctx, opt.GlobalOptions.Timeout)
defer cancel() 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) 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) return xerrors.Errorf("unable to write results: %w", err)
} }

View File

@@ -59,8 +59,8 @@ func (r *Report) Failed() bool {
} }
// Write writes the results in the give format // Write writes the results in the give format
func Write(rep *Report, opt flag.Options, fromCache bool) error { func Write(ctx context.Context, rep *Report, opt flag.Options, fromCache bool) error {
output, cleanup, err := opt.OutputWriter() output, cleanup, err := opt.OutputWriter(ctx)
if err != nil { if err != nil {
return xerrors.Errorf("failed to create output file: %w", err) 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 var filtered []types.Result
ctx := context.Background()
// filter results // filter results
for _, resultsAtTime := range rep.Results { for _, resultsAtTime := range rep.Results {
for _, res := range resultsAtTime.Results { for _, res := range resultsAtTime.Results {
@@ -137,7 +135,7 @@ func Write(rep *Report, opt flag.Options, fromCache bool) error {
return nil return nil
default: default:
return pkgReport.Write(base, opt) return pkgReport.Write(ctx, base, opt)
} }
} }

View File

@@ -2,6 +2,7 @@ package report
import ( import (
"bytes" "bytes"
"context"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -111,7 +112,7 @@ No problems detected.
output := bytes.NewBuffer(nil) output := bytes.NewBuffer(nil)
tt.options.SetOutputWriter(output) 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, "AWS", report.Provider)
assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID) assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID)

View File

@@ -2,6 +2,7 @@ package report
import ( import (
"bytes" "bytes"
"context"
"strings" "strings"
"testing" "testing"
@@ -70,7 +71,7 @@ See https://avd.aquasec.com/misconfig/avd-aws-9999
output := bytes.NewBuffer(nil) output := bytes.NewBuffer(nil)
tt.options.SetOutputWriter(output) 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, "AWS", report.Provider)
assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID) assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID)

View File

@@ -2,6 +2,7 @@ package report
import ( import (
"bytes" "bytes"
"context"
"github.com/aquasecurity/trivy/pkg/clock" "github.com/aquasecurity/trivy/pkg/clock"
"testing" "testing"
"time" "time"
@@ -322,7 +323,7 @@ Scan Overview for AWS Account
output := bytes.NewBuffer(nil) output := bytes.NewBuffer(nil)
tt.options.SetOutputWriter(output) 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, "AWS", report.Provider)
assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID) assert.Equal(t, tt.options.AWSOptions.Account, report.AccountID)

View File

@@ -124,7 +124,7 @@ func loadPluginCommands() []*cobra.Command {
Short: p.Usage, Short: p.Usage,
GroupID: groupPlugin, GroupID: groupPlugin,
RunE: func(cmd *cobra.Command, args []string) error { 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 xerrors.Errorf("plugin error: %w", err)
} }
return nil return nil
@@ -773,7 +773,7 @@ func NewPluginCommand() *cobra.Command {
Short: "Run a plugin on the fly", Short: "Run a plugin on the fly",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { 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{ &cobra.Command{

View File

@@ -91,7 +91,7 @@ type Runner interface {
// Filter filter a report // Filter filter a report
Filter(ctx context.Context, opts flag.Options, report types.Report) (types.Report, error) Filter(ctx context.Context, opts flag.Options, report types.Report) (types.Report, error)
// Report a writes a report // 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 closes runner
Close(ctx context.Context) error 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 return report, nil
} }
func (r *runner) Report(opts flag.Options, report types.Report) error { func (r *runner) Report(ctx context.Context, opts flag.Options, report types.Report) error {
if err := pkgReport.Write(report, opts); err != nil { if err := pkgReport.Write(ctx, report, opts); err != nil {
return xerrors.Errorf("unable to write results: %w", err) 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) 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) return xerrors.Errorf("report error: %w", err)
} }

View File

@@ -16,6 +16,9 @@ import (
) )
func Run(ctx context.Context, opts flag.Options) (err error) { 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) f, err := os.Open(opts.Target)
if err != nil { if err != nil {
return xerrors.Errorf("file open error: %w", err) 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...") 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) return xerrors.Errorf("unable to write results: %w", err)
} }

View File

@@ -1,6 +1,7 @@
package flag package flag
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -17,6 +18,7 @@ import (
"github.com/aquasecurity/trivy/pkg/fanal/analyzer" "github.com/aquasecurity/trivy/pkg/fanal/analyzer"
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/plugin"
"github.com/aquasecurity/trivy/pkg/result" "github.com/aquasecurity/trivy/pkg/result"
"github.com/aquasecurity/trivy/pkg/types" "github.com/aquasecurity/trivy/pkg/types"
"github.com/aquasecurity/trivy/pkg/version" "github.com/aquasecurity/trivy/pkg/version"
@@ -173,19 +175,46 @@ func (o *Options) SetOutputWriter(w io.Writer) {
// OutputWriter returns an output writer. // OutputWriter returns an output writer.
// If the output file is not specified, it returns os.Stdout. // If the output file is not specified, it returns os.Stdout.
func (o *Options) OutputWriter() (io.Writer, func(), error) { func (o *Options) OutputWriter(ctx context.Context) (io.Writer, func() error, error) {
if o.outputWriter != nil { cleanup := func() error { return nil }
return o.outputWriter, func() {}, 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) f, err := os.Create(o.Output)
if err != nil { if err != nil {
return nil, nil, xerrors.Errorf("failed to create output file: %w", err) return nil, nil, xerrors.Errorf("failed to create output file: %w", err)
} }
return f, func() { _ = f.Close() }, nil 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)
} }
return os.Stdout, func() {}, nil
cleanup := func() error {
if err = pw.Close(); err != nil {
return xerrors.Errorf("failed to close pipe: %w", err)
}
if err = wait(); err != nil {
return xerrors.Errorf("plugin error: %w", err)
}
return nil
}
return pw, cleanup, nil
} }
func addFlag(cmd *cobra.Command, flag *Flag) { func addFlag(cmd *cobra.Command, flag *Flag) {

View File

@@ -3,6 +3,7 @@ package flag
import ( import (
"strings" "strings"
"github.com/mattn/go-shellwords"
"github.com/samber/lo" "github.com/samber/lo"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
"golang.org/x/xerrors" "golang.org/x/xerrors"
@@ -86,6 +87,12 @@ var (
Default: "", Default: "",
Usage: "output file name", Usage: "output file name",
} }
OutputPluginArgFlag = Flag{
Name: "output-plugin-arg",
ConfigName: "output-plugin-arg",
Default: "",
Usage: "[EXPERIMENTAL] output plugin arguments",
}
SeverityFlag = Flag{ SeverityFlag = Flag{
Name: "severity", Name: "severity",
ConfigName: "severity", ConfigName: "severity",
@@ -115,6 +122,7 @@ type ReportFlagGroup struct {
ExitCode *Flag ExitCode *Flag
ExitOnEOL *Flag ExitOnEOL *Flag
Output *Flag Output *Flag
OutputPluginArg *Flag
Severity *Flag Severity *Flag
Compliance *Flag Compliance *Flag
} }
@@ -130,6 +138,7 @@ type ReportOptions struct {
ExitOnEOL int ExitOnEOL int
IgnorePolicy string IgnorePolicy string
Output string Output string
OutputPluginArgs []string
Severities []dbTypes.Severity Severities []dbTypes.Severity
Compliance spec.ComplianceSpec Compliance spec.ComplianceSpec
} }
@@ -146,6 +155,7 @@ func NewReportFlagGroup() *ReportFlagGroup {
ExitCode: &ExitCodeFlag, ExitCode: &ExitCodeFlag,
ExitOnEOL: &ExitOnEOLFlag, ExitOnEOL: &ExitOnEOLFlag,
Output: &OutputFlag, Output: &OutputFlag,
OutputPluginArg: &OutputPluginArgFlag,
Severity: &SeverityFlag, Severity: &SeverityFlag,
Compliance: &ComplianceFlag, Compliance: &ComplianceFlag,
} }
@@ -167,6 +177,7 @@ func (f *ReportFlagGroup) Flags() []*Flag {
f.ExitCode, f.ExitCode,
f.ExitOnEOL, f.ExitOnEOL,
f.Output, f.Output,
f.OutputPluginArg,
f.Severity, f.Severity,
f.Compliance, f.Compliance,
} }
@@ -216,6 +227,14 @@ func (f *ReportFlagGroup) ToOptions() (ReportOptions, error) {
return ReportOptions{}, xerrors.Errorf("unable to load compliance spec: %w", err) 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{ return ReportOptions{
Format: format, Format: format,
ReportFormat: getString(f.ReportFormat), ReportFormat: getString(f.ReportFormat),
@@ -227,6 +246,7 @@ func (f *ReportFlagGroup) ToOptions() (ReportOptions, error) {
ExitOnEOL: getInt(f.ExitOnEOL), ExitOnEOL: getInt(f.ExitOnEOL),
IgnorePolicy: getString(f.IgnorePolicy), IgnorePolicy: getString(f.IgnorePolicy),
Output: getString(f.Output), Output: getString(f.Output),
OutputPluginArgs: outputPluginArgs,
Severities: toSeverity(getStringSlice(f.Severity)), Severities: toSeverity(getStringSlice(f.Severity)),
Compliance: cs, Compliance: cs,
}, nil }, nil

View File

@@ -28,9 +28,9 @@ func TestReportFlagGroup_ToOptions(t *testing.T) {
exitOnEOSL bool exitOnEOSL bool
ignorePolicy string ignorePolicy string
output string output string
outputPluginArgs string
severities string severities string
compliane string compliance string
debug bool debug bool
} }
tests := []struct { tests := []struct {
@@ -63,7 +63,6 @@ func TestReportFlagGroup_ToOptions(t *testing.T) {
severities: "CRITICAL", severities: "CRITICAL",
format: "cyclonedx", format: "cyclonedx",
listAllPkgs: false, listAllPkgs: false,
debug: true, debug: true,
}, },
wantLogs: []string{ wantLogs: []string{
@@ -138,10 +137,26 @@ func TestReportFlagGroup_ToOptions(t *testing.T) {
ListAllPkgs: true, 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", name: "happy path with compliance",
fields: fields{ fields: fields{
compliane: "@testdata/example-spec.yaml", compliance: "@testdata/example-spec.yaml",
severities: dbTypes.SeverityLow.String(), severities: dbTypes.SeverityLow.String(),
}, },
want: flag.ReportOptions{ want: flag.ReportOptions{
@@ -187,8 +202,9 @@ func TestReportFlagGroup_ToOptions(t *testing.T) {
viper.Set(flag.ExitCodeFlag.ConfigName, tt.fields.exitCode) viper.Set(flag.ExitCodeFlag.ConfigName, tt.fields.exitCode)
viper.Set(flag.ExitOnEOLFlag.ConfigName, tt.fields.exitOnEOSL) viper.Set(flag.ExitOnEOLFlag.ConfigName, tt.fields.exitOnEOSL)
viper.Set(flag.OutputFlag.ConfigName, tt.fields.output) 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.SeverityFlag.ConfigName, tt.fields.severities)
viper.Set(flag.ComplianceFlag.ConfigName, tt.fields.compliane) viper.Set(flag.ComplianceFlag.ConfigName, tt.fields.compliance)
// Assert options // Assert options
f := &flag.ReportFlagGroup{ f := &flag.ReportFlagGroup{
@@ -201,6 +217,7 @@ func TestReportFlagGroup_ToOptions(t *testing.T) {
ExitCode: &flag.ExitCodeFlag, ExitCode: &flag.ExitCodeFlag,
ExitOnEOL: &flag.ExitOnEOLFlag, ExitOnEOL: &flag.ExitOnEOLFlag,
Output: &flag.OutputFlag, Output: &flag.OutputFlag,
OutputPluginArg: &flag.OutputPluginArgFlag,
Severity: &flag.SeverityFlag, Severity: &flag.SeverityFlag,
Compliance: &flag.ComplianceFlag, Compliance: &flag.ComplianceFlag,
} }

View File

@@ -101,7 +101,7 @@ func (r *runner) run(ctx context.Context, artifacts []*k8sArtifacts.Artifact) er
return xerrors.Errorf("k8s scan error: %w", err) return xerrors.Errorf("k8s scan error: %w", err)
} }
output, cleanup, err := r.flagOpts.OutputWriter() output, cleanup, err := r.flagOpts.OutputWriter(ctx)
if err != nil { if err != nil {
return xerrors.Errorf("failed to create output file: %w", err) return xerrors.Errorf("failed to create output file: %w", err)
} }

View File

@@ -3,6 +3,7 @@ package plugin
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -57,21 +58,55 @@ type Selector struct {
Arch string Arch string
} }
// Run runs the plugin type RunOptions struct {
func (p Plugin) Run(ctx context.Context, args []string) error { Args []string
Stdin io.Reader
}
func (p Plugin) Cmd(ctx context.Context, opts RunOptions) (*exec.Cmd, error) {
platform, err := p.selectPlatform() platform, err := p.selectPlatform()
if err != nil { 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) 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 cmd.Stdin = os.Stdin
if opts.Stdin != nil {
cmd.Stdin = opts.Stdin
}
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Env = os.Environ() 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 // 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 // out if the error was from not being able to execute the plugin or
// an error set by the plugin itself. // 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 { if _, ok := err.(*exec.ExitError); !ok {
return xerrors.Errorf("exit: %w", err) return xerrors.Errorf("exit: %w", err)
} }
return xerrors.Errorf("plugin exec: %w", err) return xerrors.Errorf("plugin exec: %w", err)
} }
return nil return nil
} }
@@ -186,18 +219,9 @@ func Uninstall(name string) error {
// Information gets the information about an installed plugin // Information gets the information about an installed plugin
func Information(name string) (string, error) { func Information(name string) (string, error) {
pluginDir := filepath.Join(dir(), name) plugin, err := load(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)
if err != nil { if err != nil {
return "", xerrors.Errorf("unable to load metadata: %w", err) return "", xerrors.Errorf("plugin load error: %w", err)
} }
return fmt.Sprintf(` return fmt.Sprintf(`
@@ -230,19 +254,11 @@ func List() (string, error) {
// Update updates an existing plugin // Update updates an existing plugin
func Update(name string) error { func Update(name string) error {
pluginDir := filepath.Join(dir(), name) plugin, err := load(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)
if err != nil { if err != nil {
return err return xerrors.Errorf("plugin load error: %w", err)
} }
log.Logger.Infof("Updating plugin '%s'", name) log.Logger.Infof("Updating plugin '%s'", name)
updated, err := Install(nil, plugin.Repository, true) updated, err := Install(nil, plugin.Repository, true)
if err != nil { if err != nil {
@@ -280,15 +296,29 @@ func LoadAll() ([]Plugin, error) {
return plugins, nil return plugins, nil
} }
// RunWithArgs runs the plugin with arguments // Start starts the plugin
func RunWithArgs(ctx context.Context, url string, args []string) error { func Start(ctx context.Context, name string, opts RunOptions) (Wait, error) {
pl, err := Install(ctx, url, false) 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 { if err != nil {
return xerrors.Errorf("plugin install error: %w", err) return xerrors.Errorf("plugin install error: %w", err)
} }
if err = pl.Run(ctx, args); err != nil { if err = plugin.Run(ctx, opts); err != nil {
return xerrors.Errorf("unable to run %s plugin: %w", pl.Name, err) return xerrors.Errorf("unable to run %s plugin: %w", plugin.Name, err)
} }
return nil return nil
} }
@@ -298,6 +328,23 @@ func IsPredefined(name string) bool {
return ok 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) { func loadMetadata(dir string) (Plugin, error) {
filePath := filepath.Join(dir, configFile) filePath := filepath.Join(dir, configFile)
f, err := os.Open(filePath) f, err := os.Open(filePath)

View File

@@ -29,13 +29,10 @@ func TestPlugin_Run(t *testing.T) {
GOOS string GOOS string
GOARCH string GOARCH string
} }
type args struct {
args []string
}
tests := []struct { tests := []struct {
name string name string
fields fields fields fields
args args opts plugin.RunOptions
wantErr string wantErr string
}{ }{
{ {
@@ -162,7 +159,7 @@ func TestPlugin_Run(t *testing.T) {
GOARCH: tt.fields.GOARCH, GOARCH: tt.fields.GOARCH,
} }
err := p.Run(context.Background(), tt.args.args) err := p.Run(context.Background(), tt.opts)
if tt.wantErr != "" { if tt.wantErr != "" {
require.NotNil(t, err) require.NotNil(t, err)
assert.Contains(t, err.Error(), tt.wantErr) assert.Contains(t, err.Error(), tt.wantErr)
@@ -338,7 +335,7 @@ description: A simple test plugin`
// Get Information for unknown plugin // Get Information for unknown plugin
info, err = plugin.Information("unknown") info, err = plugin.Information("unknown")
require.Error(t, err) 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) { func TestLoadAll1(t *testing.T) {

View File

@@ -1,6 +1,8 @@
package report package report
import ( import (
"context"
"errors"
"io" "io"
"strings" "strings"
"sync" "sync"
@@ -24,12 +26,16 @@ const (
) )
// Write writes the result to output, format as passed in argument // Write writes the result to output, format as passed in argument
func Write(report types.Report, option flag.Options) error { func Write(ctx context.Context, report types.Report, option flag.Options) (err error) {
output, cleanup, err := option.OutputWriter() output, cleanup, err := option.OutputWriter(ctx)
if err != nil { if err != nil {
return xerrors.Errorf("failed to create a file: %w", err) 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 // Compliance report
if option.Compliance.Spec.ID != "" { 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) 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 xerrors.Errorf("failed to write results: %w", err)
} }
return nil return nil
} }