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) {
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

View File

@@ -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=<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
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/

View File

@@ -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 <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
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)
--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

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])
--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

View File

@@ -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

View File

@@ -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")

View File

@@ -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

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")
--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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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

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-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

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/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=

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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{

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
return f, func() { _ = f.Close() }, nil
f, err := os.Create(o.Output)
if err != nil {
return nil, nil, xerrors.Errorf("failed to create output file: %w", err)
}
return os.Stdout, func() {}, 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)
}
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) {

View File

@@ -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
}

View File

@@ -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()

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)
}
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)
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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
}