diff --git a/docs/docs/references/configuration/cli/trivy.md b/docs/docs/references/configuration/cli/trivy.md index a22b0a937640..9776926b8386 100644 --- a/docs/docs/references/configuration/cli/trivy.md +++ b/docs/docs/references/configuration/cli/trivy.md @@ -45,14 +45,11 @@ trivy [global flags] command [flags] target ### SEE ALSO * [trivy clean](trivy_clean.md) - Remove cached files -* [trivy cloud](trivy_cloud.md) - Control Trivy Cloud platform integration settings * [trivy config](trivy_config.md) - Scan config files for misconfigurations * [trivy convert](trivy_convert.md) - Convert Trivy JSON report into a different format * [trivy filesystem](trivy_filesystem.md) - Scan local filesystem * [trivy image](trivy_image.md) - Scan a container image * [trivy kubernetes](trivy_kubernetes.md) - [EXPERIMENTAL] Scan kubernetes cluster -* [trivy login](trivy_login.md) - Log in to the Trivy Cloud platform -* [trivy logout](trivy_logout.md) - Log out of Trivy Cloud platform * [trivy module](trivy_module.md) - Manage modules * [trivy plugin](trivy_plugin.md) - Manage plugins * [trivy registry](trivy_registry.md) - Manage registry authentication diff --git a/docs/docs/references/configuration/cli/trivy_config.md b/docs/docs/references/configuration/cli/trivy_config.md index 899e8e55c9e5..c85554746692 100644 --- a/docs/docs/references/configuration/cli/trivy_config.md +++ b/docs/docs/references/configuration/cli/trivy_config.md @@ -52,6 +52,12 @@ trivy config [flags] DIR --output-plugin-arg string [EXPERIMENTAL] output plugin arguments --password strings password. Comma-separated passwords allowed. TRIVY_PASSWORD should be used for security reasons. --password-stdin password from stdin. Comma-separated passwords are not supported. + --pro-api-url string API URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://api.trivy.dev") + --pro-server-scanning Use server-side image scanning in Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-token string Token used to athenticate with Trivy Pro platform + --pro-trivy-server-url string Trivy Server URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://scan.trivy.dev") + --pro-upload-results Upload results to Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-use-secret-config Use secret configurations from Trivy Pro platform, requires the token to be provided to have an effect (default true) --raw-config-scanners strings specify the types of scanners that will also scan raw configurations. For example, scanners will scan a non-adapted configuration into a shared state (allowed values: terraform) --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 diff --git a/docs/docs/references/configuration/cli/trivy_filesystem.md b/docs/docs/references/configuration/cli/trivy_filesystem.md index dde53f11d91e..9b1eab682ecc 100644 --- a/docs/docs/references/configuration/cli/trivy_filesystem.md +++ b/docs/docs/references/configuration/cli/trivy_filesystem.md @@ -100,6 +100,12 @@ trivy filesystem [flags] PATH - indirect (default [unknown,root,workspace,direct,indirect]) --pkg-types strings list of package types (allowed values: os,library) (default [os,library]) + --pro-api-url string API URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://api.trivy.dev") + --pro-server-scanning Use server-side image scanning in Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-token string Token used to athenticate with Trivy Pro platform + --pro-trivy-server-url string Trivy Server URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://scan.trivy.dev") + --pro-upload-results Upload results to Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-use-secret-config Use secret configurations from Trivy Pro platform, requires the token to be provided to have an effect (default true) --raw-config-scanners strings specify the types of scanners that will also scan raw configurations. For example, scanners will scan a non-adapted configuration into a shared state (allowed values: terraform) --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 diff --git a/docs/docs/references/configuration/cli/trivy_image.md b/docs/docs/references/configuration/cli/trivy_image.md index a00d0801ccf5..d4154455e2b9 100644 --- a/docs/docs/references/configuration/cli/trivy_image.md +++ b/docs/docs/references/configuration/cli/trivy_image.md @@ -121,6 +121,12 @@ trivy image [flags] IMAGE_NAME --pkg-types strings list of package types (allowed values: os,library) (default [os,library]) --platform string set platform in the form os/arch if image is multi-platform capable --podman-host string unix podman socket path to use for podman scanning + --pro-api-url string API URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://api.trivy.dev") + --pro-server-scanning Use server-side image scanning in Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-token string Token used to athenticate with Trivy Pro platform + --pro-trivy-server-url string Trivy Server URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://scan.trivy.dev") + --pro-upload-results Upload results to Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-use-secret-config Use secret configurations from Trivy Pro platform, requires the token to be provided to have an effect (default true) --raw-config-scanners strings specify the types of scanners that will also scan raw configurations. For example, scanners will scan a non-adapted configuration into a shared state (allowed values: terraform) --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 diff --git a/docs/docs/references/configuration/cli/trivy_repository.md b/docs/docs/references/configuration/cli/trivy_repository.md index 1a6ac9bd93bd..902deeacc103 100644 --- a/docs/docs/references/configuration/cli/trivy_repository.md +++ b/docs/docs/references/configuration/cli/trivy_repository.md @@ -99,6 +99,12 @@ trivy repository [flags] (REPO_PATH | REPO_URL) - indirect (default [unknown,root,workspace,direct,indirect]) --pkg-types strings list of package types (allowed values: os,library) (default [os,library]) + --pro-api-url string API URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://api.trivy.dev") + --pro-server-scanning Use server-side image scanning in Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-token string Token used to athenticate with Trivy Pro platform + --pro-trivy-server-url string Trivy Server URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://scan.trivy.dev") + --pro-upload-results Upload results to Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-use-secret-config Use secret configurations from Trivy Pro platform, requires the token to be provided to have an effect (default true) --raw-config-scanners strings specify the types of scanners that will also scan raw configurations. For example, scanners will scan a non-adapted configuration into a shared state (allowed values: terraform) --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 diff --git a/docs/docs/references/configuration/cli/trivy_rootfs.md b/docs/docs/references/configuration/cli/trivy_rootfs.md index 8a0d51085daa..c391d7d65a39 100644 --- a/docs/docs/references/configuration/cli/trivy_rootfs.md +++ b/docs/docs/references/configuration/cli/trivy_rootfs.md @@ -102,6 +102,12 @@ trivy rootfs [flags] ROOTDIR - indirect (default [unknown,root,workspace,direct,indirect]) --pkg-types strings list of package types (allowed values: os,library) (default [os,library]) + --pro-api-url string API URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://api.trivy.dev") + --pro-server-scanning Use server-side image scanning in Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-token string Token used to athenticate with Trivy Pro platform + --pro-trivy-server-url string Trivy Server URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://scan.trivy.dev") + --pro-upload-results Upload results to Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-use-secret-config Use secret configurations from Trivy Pro platform, requires the token to be provided to have an effect (default true) --raw-config-scanners strings specify the types of scanners that will also scan raw configurations. For example, scanners will scan a non-adapted configuration into a shared state (allowed values: terraform) --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 diff --git a/docs/docs/references/configuration/cli/trivy_vm.md b/docs/docs/references/configuration/cli/trivy_vm.md index 4fbb5d4d4a1e..e93d1501cf76 100644 --- a/docs/docs/references/configuration/cli/trivy_vm.md +++ b/docs/docs/references/configuration/cli/trivy_vm.md @@ -92,6 +92,12 @@ trivy vm [flags] VM_IMAGE - indirect (default [unknown,root,workspace,direct,indirect]) --pkg-types strings list of package types (allowed values: os,library) (default [os,library]) + --pro-api-url string API URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://api.trivy.dev") + --pro-server-scanning Use server-side image scanning in Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-token string Token used to athenticate with Trivy Pro platform + --pro-trivy-server-url string Trivy Server URL for Trivy Pro platform, requires the token to be provided to have an effect (default "https://scan.trivy.dev") + --pro-upload-results Upload results to Trivy Pro platform, requires the token to be provided to have an effect (default true) + --pro-use-secret-config Use secret configurations from Trivy Pro platform, requires the token to be provided to have an effect (default true) --raw-config-scanners strings specify the types of scanners that will also scan raw configurations. For example, scanners will scan a non-adapted configuration into a shared state (allowed values: terraform) --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 diff --git a/go.mod b/go.mod index ee84743404c8..7e5b35bba29a 100644 --- a/go.mod +++ b/go.mod @@ -111,7 +111,7 @@ require ( github.com/twitchtv/twirp v8.1.3+incompatible github.com/xeipuuv/gojsonschema v1.2.0 github.com/xlab/treeprint v1.2.0 - github.com/zalando/go-keyring v0.2.6 + github.com/zalando/go-keyring v0.2.6 // indirect github.com/zclconf/go-cty v1.17.0 github.com/zclconf/go-cty-yaml v1.1.0 go.etcd.io/bbolt v1.4.3 @@ -133,7 +133,6 @@ require ( ) require ( - al.essio.dev/pkg/shellescape v1.5.1 // indirect buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.6-20250718181942-e35f9b667443.1 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 // indirect buf.build/gen/go/bufbuild/registry/connectrpc/go v1.18.1-20250721151928-2b7ae473b098.1 // indirect @@ -226,7 +225,6 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20231011164504-785e29786b46 // indirect github.com/cyphar/filepath-securejoin v0.6.0 // indirect - github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -283,7 +281,6 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.15.23 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/gofrs/uuid v4.3.1+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect diff --git a/go.sum b/go.sum index 2bcddb97b2d6..a92fceaee8af 100644 --- a/go.sum +++ b/go.sum @@ -702,8 +702,6 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 h1:SJ+NtwL6QaZ21U+IrK7d0gGgpjGGvd2kz+FzTHVzdqI= github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2/go.mod h1:Tv1PlzqC9t8wNnpPdctvtSUOPUUg4SHeE6vR1Ir2hmg= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM= github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= diff --git a/mkdocs.yml b/mkdocs.yml index 14c7a63eae1c..fd044aecd53c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -169,14 +169,6 @@ nav: - CLI: - Overview: docs/references/configuration/cli/trivy.md - Clean: docs/references/configuration/cli/trivy_clean.md - - Cloud: - - Cloud: docs/references/configuration/cli/trivy_cloud.md - - Cloud Config: docs/references/configuration/cli/trivy_cloud_config.md - - Cloud Config Edit: docs/references/configuration/cli/trivy_cloud_config_edit.md - - Cloud Config List: docs/references/configuration/cli/trivy_cloud_config_list.md - - Cloud Config Set: docs/references/configuration/cli/trivy_cloud_config_set.md - - Cloud Config Unset: docs/references/configuration/cli/trivy_cloud_config_unset.md - - Cloud Config Get: docs/references/configuration/cli/trivy_cloud_config_get.md - Config: docs/references/configuration/cli/trivy_config.md - Convert: docs/references/configuration/cli/trivy_convert.md - Filesystem: docs/references/configuration/cli/trivy_filesystem.md diff --git a/pkg/cloud/config.go b/pkg/cloud/config.go deleted file mode 100644 index 64828bb08ae9..000000000000 --- a/pkg/cloud/config.go +++ /dev/null @@ -1,298 +0,0 @@ -package cloud - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/url" - "os" - "path/filepath" - "reflect" - "strconv" - "strings" - - "github.com/samber/lo" - "github.com/zalando/go-keyring" - "golang.org/x/xerrors" - "gopkg.in/yaml.v3" - - "github.com/aquasecurity/trivy/pkg/log" - "github.com/aquasecurity/trivy/pkg/utils/fsutils" - xhttp "github.com/aquasecurity/trivy/pkg/x/http" -) - -const ( - ServiceName = "trivy-cloud" - TokenKey = "token" - DefaultApiUrl = "https://api.trivy.dev" - DefaultTrivyServerUrl = "https://scan.trivy.dev" -) - -type Api struct { - URL string `yaml:"url"` -} - -type Scanning struct { - Enabled bool `yaml:"enabled"` - UploadResults bool `yaml:"upload-results"` - SecretConfig bool `yaml:"secret-config"` - MisconfigConfig bool `yaml:"misconfig-config"` -} - -type Server struct { - URL string `yaml:"url"` - Scanning Scanning `yaml:"scanning"` -} - -type Config struct { - Api Api `yaml:"api"` - Server Server `yaml:"server"` - IsLoggedIn bool `yaml:"-"` - Token string `yaml:"-"` -} - -var defaultConfig = &Config{ - Api: Api{ - URL: DefaultApiUrl, - }, - Server: Server{ - URL: DefaultTrivyServerUrl, - Scanning: Scanning{}, - }, -} - -func getConfigPath() string { - configFileName := fmt.Sprintf("%s.yaml", ServiceName) - return filepath.Join(fsutils.TrivyHomeDir(), configFileName) -} - -func (c *Config) Save() error { - if c.Token == "" && c.Server.URL == "" && c.Api.URL == "" { - return xerrors.New("no config to save, required fields are token, server url, and api url") - } - - if err := c.initFirstLogin(); err != nil { - return err - } - - if err := keyring.Set(ServiceName, TokenKey, c.Token); err != nil { - return err - } - - configPath := getConfigPath() - if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { - return err - } - - configYaml, err := yaml.Marshal(c) - if err != nil { - return err - } - - yamlWithFrontmatter := append([]byte("---\n"), configYaml...) - return os.WriteFile(configPath, yamlWithFrontmatter, 0o600) -} - -func Clear() error { - if err := keyring.Delete(ServiceName, TokenKey); err != nil { - if !errors.Is(err, keyring.ErrNotFound) { - return err - } - } - - configPath := getConfigPath() - if err := os.Remove(configPath); err != nil { - if !errors.Is(err, os.ErrNotExist) { - return err - } - } - - return nil -} - -// initFirstLogin initializes the default scanning settings to turn them on -// after this, the user can configure in the config using the config set/unset commands -func (c *Config) initFirstLogin() error { - if c.Token == "" { - // this isn't a login save, without a token it can't login - return nil - } - - var firstLogin bool - _, err := keyring.Get(ServiceName, TokenKey) - if err != nil { - if !errors.Is(err, keyring.ErrNotFound) { - return err - } - firstLogin = true - } - - if firstLogin { - // if first login, turn on all scanning options - c.Server.Scanning.Enabled = true - c.Server.Scanning.UploadResults = true - c.Server.Scanning.MisconfigConfig = true - c.Server.Scanning.SecretConfig = true - } - return nil -} - -// Load loads the Trivy Cloud config from the config file and the keychain -// If the config file does not exist the default config is returned -func Load() (*Config, error) { - logger := log.WithPrefix(log.PrefixCloud) - var config Config - configPath := getConfigPath() - yamlData, err := os.ReadFile(configPath) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return nil, err - } - logger.Debug("No cloud config file found") - defaultCopy := *defaultConfig - return &defaultCopy, nil - } - if err := yaml.Unmarshal(yamlData, &config); err != nil { - return nil, err - } - - token, err := keyring.Get(ServiceName, TokenKey) - if err != nil { - if !errors.Is(err, keyring.ErrNotFound) { - return nil, err - } - logger.Debug("No token found in keychain") - config.Token = "" - return &config, nil - } - - config.Token = token - return &config, nil -} - -// Verify verifies the Trivy Cloud token and server URL and sets the global cloud config -// if the token is valid, the IsLoggedIn field is set to true and the global loggedIn variable is set to true -func (c *Config) Verify(ctx context.Context) error { - if c.Token == "" { - return xerrors.New("no token provided for verification") - } - - if c.Server.URL == "" { - return xerrors.New("no server URL provided for verification") - } - - logger := log.WithPrefix(log.PrefixCloud) - - client := xhttp.Client() - url, err := url.JoinPath(c.Server.URL, "verify") - if err != nil { - return xerrors.Errorf("failed to join server URL and verify path: %w", err) - } - - logger.Debug("Verifying Trivy Cloud token against server", log.String("verification_url", url)) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, http.NoBody) - if err != nil { - return xerrors.Errorf("failed to create verification request: %w", err) - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) - resp, err := client.Do(req) - if err != nil { - return xerrors.Errorf("failed to verify token: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return xerrors.Errorf("failed to verify token: received status code %d", resp.StatusCode) - } - - logger.Debug("Trivy Cloud token verified successfully") - return nil - -} - -// ListConfig shows the Trivy Cloud config in human readable format -func ListConfig() error { - cloudConfig, err := Load() - if err != nil { - return xerrors.Errorf("failed to load Trivy Cloud config file: %w", err) - } - - var loggedIn bool - if cloudConfig.Verify(context.Background()) == nil { - loggedIn = true - } else { - loggedIn = false - } - - fmt.Println() - fmt.Println("Trivy Cloud Configuration") - fmt.Println("-------------------------") - fmt.Printf("Filepath: %s\n", getConfigPath()) - fmt.Printf("Logged In: %s\n", lo.Ternary(loggedIn, "Yes", "No")) - fmt.Println() - - fields := collectConfigFields(reflect.ValueOf(cloudConfig).Elem(), "") - maxKeyLen := 0 - for _, field := range fields { - maxKeyLen = max(maxKeyLen, len(field.path)) - } - - for _, field := range fields { - fmt.Printf("%-*s %s\n", maxKeyLen, field.path, formatValue(field.value)) - } - - fmt.Println() - - return nil -} - -type configField struct { - path string - value reflect.Value -} - -func collectConfigFields(v reflect.Value, prefix string) []configField { - var fields []configField - t := v.Type() - - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - fieldValue := v.Field(i) - - yamlTag := field.Tag.Get("yaml") - if yamlTag == "-" || yamlTag == "" { - continue - } - - tagName := strings.Split(yamlTag, ",")[0] - fullPath := tagName - if prefix != "" { - fullPath = prefix + "." + tagName - } - if fieldValue.Kind() == reflect.Struct { - fields = append(fields, collectConfigFields(fieldValue, fullPath)...) - } else { - fields = append(fields, configField{ - path: fullPath, - value: fieldValue, - }) - } - } - - return fields -} - -func formatValue(v reflect.Value) string { - switch v.Kind() { - case reflect.Bool: - return lo.Ternary(v.Bool(), "Enabled", "Disabled") - case reflect.String: - return v.String() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return strconv.FormatInt(v.Int(), 10) - case reflect.Float32, reflect.Float64: - return fmt.Sprintf("%f", v.Float()) - default: - return fmt.Sprintf("%v", v.Interface()) - } -} diff --git a/pkg/cloud/config_edit.go b/pkg/cloud/config_edit.go deleted file mode 100644 index ac05dc0102a7..000000000000 --- a/pkg/cloud/config_edit.go +++ /dev/null @@ -1,44 +0,0 @@ -package cloud - -import ( - "os" - "os/exec" - "runtime" - - "github.com/aquasecurity/trivy/pkg/log" - "github.com/aquasecurity/trivy/pkg/utils/fsutils" -) - -// OpenConfigForEditing opens the Trivy Cloud config file for editing in the default editor specified in the EDITOR environment variable -func OpenConfigForEditing() error { - configPath := getConfigPath() - - logger := log.WithPrefix(log.PrefixCloud) - if !fsutils.FileExists(configPath) { - logger.Debug("Trivy Cloud config file does not exist", log.String("config_path", configPath)) - defaultConfig.Save() - configPath = getConfigPath() - } - - editor := getEditCommand() - - cmd := exec.Command(editor, configPath) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() -} - -func getEditCommand() string { - editor := os.Getenv("EDITOR") - if editor != "" { - return editor - } - - // fallback to notepad for windows or vi for macos/linux - if runtime.GOOS == "windows" { - return "notepad" - } - return "vi" -} diff --git a/pkg/cloud/config_modify.go b/pkg/cloud/config_modify.go deleted file mode 100644 index 9dd8bf11a93d..000000000000 --- a/pkg/cloud/config_modify.go +++ /dev/null @@ -1,147 +0,0 @@ -package cloud - -import ( - "reflect" - "strings" - - "golang.org/x/xerrors" - "gopkg.in/yaml.v3" -) - -// Set sets a nested field in the Trivy Cloud config -func Set(attribute string, value any) error { - config, err := Load() - if err != nil { - return xerrors.Errorf("failed to load Trivy Cloud config file: %w", err) - } - - if err := setNestedField(reflect.ValueOf(config).Elem(), attribute, value); err != nil { - return xerrors.Errorf("failed to set attribute %q: %w", attribute, err) - } - - return config.Save() -} - -// Unset sets a nested field in the Trivy Cloud config to its default value -func Unset(attribute string) error { - config, err := Load() - if err != nil { - return xerrors.Errorf("failed to load Trivy Cloud config file: %w", err) - } - - if err := unsetNestedField(reflect.ValueOf(config).Elem(), attribute); err != nil { - return xerrors.Errorf("failed to unset attribute %q: %w", attribute, err) - } - - return config.Save() -} - -func unsetNestedField(value reflect.Value, attribute string) error { - field, err := navigateToField(value, attribute) - if err != nil { - return err - } - - defaultField, err := navigateToField(reflect.ValueOf(defaultConfig).Elem(), attribute) - if err != nil { - return err - } - - field.Set(defaultField) - return nil -} - -// Get gets a nested field from the Trivy Cloud config -func Get(attribute string) (any, error) { - return GetWithDefault[any](attribute, nil) -} - -// GetWithDefault gets a nested field from the Trivy Cloud config with a default value -func GetWithDefault[T any](attribute string, defaultValue T) (T, error) { - config, err := Load() - if err != nil { - return defaultValue, xerrors.Errorf("failed to load Trivy Cloud config file: %w", err) - } - - field, err := navigateToField(reflect.ValueOf(config).Elem(), attribute) - if err != nil { - return defaultValue, xerrors.Errorf("failed to get attribute %q: %w", attribute, err) - } - - return field.Interface().(T), nil -} - -func setNestedField(v reflect.Value, path string, value any) error { - field, err := navigateToField(v, path) - if err != nil { - return err - } - - convertedValue, err := convertToType(value, field.Type()) - if err != nil { - return xerrors.Errorf("failed to convert value: %w", err) - } - - field.Set(convertedValue) - return nil -} - -func convertToType(value any, targetType reflect.Type) (reflect.Value, error) { - val := reflect.ValueOf(value) - if val.Type().AssignableTo(targetType) { - return val, nil - } - targetPtr := reflect.New(targetType) // *T - targetInterface := targetPtr.Interface() - data, err := yaml.Marshal(value) - if err != nil { - return reflect.Value{}, xerrors.Errorf("failed to marshal value: %w", err) - } - if err := yaml.Unmarshal(data, targetInterface); err != nil { - return reflect.Value{}, xerrors.Errorf("failed to decode into %v: %w", targetType, err) - } - return targetPtr.Elem(), nil -} - -func navigateToField(v reflect.Value, path string) (reflect.Value, error) { - parts := strings.Split(path, ".") - if len(parts) == 0 { - return reflect.Value{}, xerrors.New("empty attribute path") - } - - for i, part := range parts { - fieldName := yamlTagToFieldName(v, part) - if fieldName == "" { - return reflect.Value{}, xerrors.Errorf("field %q not found in config", part) - } - - field := v.FieldByName(fieldName) - if !field.IsValid() { - return reflect.Value{}, xerrors.Errorf("field %q not found", fieldName) - } - if !field.CanSet() { - return reflect.Value{}, xerrors.Errorf("field %q cannot be set", fieldName) - } - - if i == len(parts)-1 { - return field, nil - } - - v = field - } - - return reflect.Value{}, xerrors.New("unexpected end of path") -} - -func yamlTagToFieldName(v reflect.Value, yamlTag string) string { - t := v.Type() - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - tag := field.Tag.Get("yaml") - tagName := strings.Split(tag, ",")[0] - if tagName == yamlTag { - return field.Name - } - } - return "" -} diff --git a/pkg/cloud/config_modify_test.go b/pkg/cloud/config_modify_test.go deleted file mode 100644 index 22c79ae612d2..000000000000 --- a/pkg/cloud/config_modify_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package cloud - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zalando/go-keyring" -) - -func TestSet(t *testing.T) { - tests := []struct { - name string - configToSet map[string]any - expected *Config - expectedError string - }{ - { - name: "success with valid config", - configToSet: map[string]any{"server.scanning.enabled": true}, - expected: &Config{Api: Api{URL: "https://api.trivy.dev"}, Server: Server{URL: "https://scan.trivy.dev", Scanning: Scanning{Enabled: true, UploadResults: false, SecretConfig: false, MisconfigConfig: false}}, IsLoggedIn: false, Token: ""}, - expectedError: "", - }, - { - name: "success with valid config using off for a boolean", - configToSet: map[string]any{"server.scanning.enabled": "on"}, - expected: &Config{Api: Api{URL: "https://api.trivy.dev"}, Server: Server{URL: "https://scan.trivy.dev", Scanning: Scanning{Enabled: true, UploadResults: false, SecretConfig: false, MisconfigConfig: false}}, IsLoggedIn: false, Token: ""}, - expectedError: "", - }, - { - name: "error with invalid config", - configToSet: map[string]any{"server.scanning.foo": false}, - expected: &Config{Api: Api{URL: "https://api.trivy.dev"}, Server: Server{URL: "https://scan.trivy.dev", Scanning: Scanning{Enabled: false, UploadResults: false, SecretConfig: false, MisconfigConfig: false}}, IsLoggedIn: false, Token: ""}, - expectedError: "field \"foo\" not found in config", - }, - { - name: "error when setting boolean with yessir", - configToSet: map[string]any{"server.scanning.enabled": "yessir"}, - expected: &Config{Api: Api{URL: "https://api.trivy.dev"}, Server: Server{URL: "https://scan.trivy.dev", Scanning: Scanning{Enabled: false, UploadResults: false, SecretConfig: false, MisconfigConfig: false}}, IsLoggedIn: false, Token: ""}, - expectedError: "cannot unmarshal !!str `yessir` into bool", - }, - { - name: "error when setting boolean with invalid value", - configToSet: map[string]any{"server.scanning.enabled": "invalid"}, - expected: &Config{Api: Api{URL: "https://api.trivy.dev"}, Server: Server{URL: "https://scan.trivy.dev", Scanning: Scanning{Enabled: false, UploadResults: false, SecretConfig: false, MisconfigConfig: false}}, IsLoggedIn: false, Token: ""}, - expectedError: "cannot unmarshal !!str `invalid` into bool", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - defer keyring.DeleteAll(ServiceName) - defer Clear() - - for key, value := range tt.configToSet { - err := Set(key, value) - if tt.expectedError != "" { - require.ErrorContains(t, err, tt.expectedError) - return - } - require.NoError(t, err) - } - - config, err := Load() - require.NoError(t, err) - assert.Equal(t, tt.expected, config) - }) - } -} - -func TestGet(t *testing.T) { - tests := []struct { - name string - primeToken bool - setupConfig *Config - attribute string - defaultValue any - expected any - expectedError string - }{ - { - name: "success with default config", - setupConfig: nil, - attribute: "server.scanning.enabled", - defaultValue: false, - expected: false, - expectedError: "", - }, - { - name: "success with custom config", - primeToken: true, - setupConfig: &Config{ - Token: "test", - Server: Server{ - URL: "https://example.com", - Scanning: Scanning{ - Enabled: false, - UploadResults: true, - SecretConfig: false, - MisconfigConfig: true, - }, - }, - Api: Api{URL: "https://api.example.com"}, - }, - attribute: "server.scanning.enabled", - defaultValue: false, - expected: false, - expectedError: "", - }, - { - name: "error with invalid attribute", - setupConfig: nil, - attribute: "server.scanning.foo", - defaultValue: true, - expected: true, - expectedError: "field \"foo\" not found in config", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - defer keyring.DeleteAll(ServiceName) - defer Clear() - - if tt.primeToken { - // add the key so the custom config isn't overwritten - require.NoError(t, keyring.Set(ServiceName, TokenKey, tt.setupConfig.Token)) - } - - if tt.setupConfig != nil { - err := tt.setupConfig.Save() - require.NoError(t, err) - } - - value, err := GetWithDefault(tt.attribute, tt.defaultValue) - if tt.expectedError != "" { - require.ErrorContains(t, err, tt.expectedError) - return - } - require.NoError(t, err) - assert.Equal(t, tt.expected, value) - }) - } -} - -func TestUnset(t *testing.T) { - tests := []struct { - name string - primeToken bool - setupConfig *Config - attribute string - expectedValue any - expectedError string - }{ - { - name: "success with default config", - setupConfig: defaultConfig, - attribute: "server.scanning.enabled", - expectedValue: false, - expectedError: "", - }, - { - name: "success with custom config", - setupConfig: &Config{ - Token: "test", - Server: Server{ - URL: "https://example.com", - Scanning: Scanning{ - Enabled: false, - UploadResults: true, - SecretConfig: false, - MisconfigConfig: true, - }, - }, - Api: Api{URL: "https://api.example.com"}, - }, - attribute: "server.scanning.enabled", - expectedValue: false, - expectedError: "", - }, - { - name: "success with custom url reset", - setupConfig: &Config{ - Token: "test", - Server: Server{ - URL: "https://example.com", - Scanning: Scanning{ - Enabled: false, - UploadResults: true, - SecretConfig: false, - MisconfigConfig: true, - }, - }, - Api: Api{URL: "https://api.custom.com"}, - }, - attribute: "api.url", - expectedValue: "https://api.trivy.dev", - expectedError: "", - }, - { - name: "error with invalid attribute", - setupConfig: defaultConfig, - attribute: "server.scanning.foo", - expectedValue: true, - expectedError: "field \"foo\" not found in config", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - defer keyring.DeleteAll(ServiceName) - defer Clear() - - if tt.primeToken { - // prime the token so it doesn't get overwritten - require.NoError(t, keyring.Set(ServiceName, TokenKey, tt.setupConfig.Token)) - } - - require.NoError(t, tt.setupConfig.Save()) - err := Unset(tt.attribute) - if tt.expectedError != "" { - require.ErrorContains(t, err, tt.expectedError) - return - } - - require.NoError(t, err) - value, err := Get(tt.attribute) - require.NoError(t, err) - assert.Equal(t, tt.expectedValue, value) - }) - } -} diff --git a/pkg/cloud/config_test.go b/pkg/cloud/config_test.go deleted file mode 100644 index e3901a53bd76..000000000000 --- a/pkg/cloud/config_test.go +++ /dev/null @@ -1,345 +0,0 @@ -package cloud - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zalando/go-keyring" -) - -func TestSave(t *testing.T) { - tests := []struct { - name string - config *Config - wantErr bool - }{ - { - name: "empty config", - config: &Config{}, - wantErr: true, - }, - { - name: "config with all fields", - config: &Config{ - Token: "test-token-123", - Server: Server{ - URL: "https://example.com", - }, - Api: Api{ - URL: "https://api.example.com", - }, - }, - wantErr: false, - }, - { - name: "config without token", - config: &Config{ - Server: Server{ - URL: "https://example.com", - }, - Api: Api{ - URL: "https://api.example.com", - }, - }, - wantErr: false, - }, - } - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - defer keyring.DeleteAll(ServiceName) - defer Clear() - - err := tt.config.Save() - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - config, err := Load() - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.config, config) - - configPath := getConfigPath() - if tt.config.Server.URL != "" || tt.config.Api.URL != "" { - assert.FileExists(t, configPath) - } - }) - } -} - -func TestClear(t *testing.T) { - tests := []struct { - name string - createConfig bool - wantErr bool - }{ - { - name: "success when nothing to clear", - wantErr: false, - }, - { - name: "success when there is config to clear", - createConfig: true, - wantErr: false, - }, - } - - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - defer keyring.DeleteAll(ServiceName) - defer Clear() - - if tt.createConfig { - config := &Config{ - Token: "testtoken", - Server: Server{ - URL: "https://example.com", - }, - } - err := config.Save() - require.NoError(t, err) - - configPath := getConfigPath() - assert.FileExists(t, configPath) - } - - err := Clear() - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - configPath := getConfigPath() - assert.NoFileExists(t, configPath) - }) - } -} - -func TestLoad(t *testing.T) { - tests := []struct { - name string - createConfig bool - expectDefault bool - }{ - { - name: "success when there is config to load", - createConfig: true, - expectDefault: false, - }, - { - name: "error when there is no config to load", - expectDefault: true, - }, - } - - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - defer keyring.DeleteAll(ServiceName) - defer Clear() - - token := "testtoken" - if tt.createConfig { - config := &Config{ - Token: token, - Server: Server{ - URL: "https://example.com", - }, - Api: Api{ - URL: "https://api.example.com", - }, - } - err := config.Save() - require.NoError(t, err) - } - - config, err := Load() - if tt.expectDefault { - assert.Equal(t, defaultConfig, config) - return - } - require.NotNil(t, config) - require.NoError(t, err) - assert.Equal(t, token, config.Token) - assert.Equal(t, "https://example.com", config.Server.URL) - assert.Equal(t, "https://api.example.com", config.Api.URL) - }) - } -} - -func TestVerify(t *testing.T) { - tests := []struct { - name string - config *Config - status int - wantErr bool - }{ - { - name: "success with valid config", - config: &Config{Token: "testtoken", Server: Server{URL: "https://example.com"}, Api: Api{URL: "https://api.example.com"}}, - status: http.StatusOK, - wantErr: false, - }, - { - name: "error with invalid config", - config: &Config{}, - status: http.StatusUnauthorized, - wantErr: true, - }, - } - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - defer keyring.DeleteAll(ServiceName) - defer Clear() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/verify", r.URL.Path) - w.WriteHeader(tt.status) - })) - defer server.Close() - - tt.config.Server.URL = server.URL - - err := tt.config.Verify(context.Background()) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - }) - } -} - -func TestListConfig(t *testing.T) { - tests := []struct { - name string - primeToken bool - setupConfig *Config - wantErr string - wantContains []string - }{ - { - name: "success with valid config", - primeToken: true, - setupConfig: &Config{ - Token: "testtoken", - Server: Server{ - URL: "https://example.com", - Scanning: Scanning{ - Enabled: true, - UploadResults: false, - SecretConfig: true, - MisconfigConfig: false, - }, - }, - Api: Api{URL: "https://api.example.com"}, - }, - wantContains: []string{ - "Trivy Cloud Configuration", - "Logged In: No", - "Filepath:", - "api.url", - "https://api.example.com", - "server.url", - "https://example.com", - "server.scanning.enabled", - "Enabled", - "server.scanning.upload-results", - "Disabled", - "server.scanning.secret-config", - "server.scanning.misconfig-config", - }, - }, - { - name: "success with default config", - setupConfig: nil, - wantContains: []string{ - "Trivy Cloud Configuration", - "Logged In: No", - "api.url", - DefaultApiUrl, - "server.url", - DefaultTrivyServerUrl, - "server.scanning.enabled", - "server.scanning.upload-results", - "server.scanning.secret-config", - "server.scanning.misconfig-config", - }, - }, - } - - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - defer keyring.DeleteAll(ServiceName) - defer Clear() - - if tt.primeToken { - // prime the token in the keyring so the custom config doesn't get overwritten - require.NoError(t, keyring.Set(ServiceName, TokenKey, tt.setupConfig.Token)) - } - - if tt.setupConfig != nil { - err := tt.setupConfig.Save() - require.NoError(t, err) - } - - r, w, err := os.Pipe() - require.NoError(t, err) - - originalStdout := os.Stdout - os.Stdout = w - - errChan := make(chan error, 1) - go func() { - errChan <- ListConfig() - w.Close() - }() - - output, _ := io.ReadAll(r) - os.Stdout = originalStdout - - err = <-errChan - if tt.wantErr != "" { - require.ErrorContains(t, err, tt.wantErr) - return - } - require.NoError(t, err) - - outputStr := string(output) - for _, want := range tt.wantContains { - assert.Contains(t, outputStr, want) - } - }) - } -} diff --git a/pkg/commands/app.go b/pkg/commands/app.go index 0b139ef553ab..ec0262c3d17e 100644 --- a/pkg/commands/app.go +++ b/pkg/commands/app.go @@ -17,7 +17,6 @@ import ( "github.com/aquasecurity/trivy/pkg/commands/artifact" "github.com/aquasecurity/trivy/pkg/commands/auth" "github.com/aquasecurity/trivy/pkg/commands/clean" - "github.com/aquasecurity/trivy/pkg/commands/cloud" "github.com/aquasecurity/trivy/pkg/commands/convert" "github.com/aquasecurity/trivy/pkg/commands/server" "github.com/aquasecurity/trivy/pkg/fanal/analyzer" @@ -82,10 +81,6 @@ func NewApp() *cobra.Command { ID: groupUtility, Title: "Utility Commands", }, - &cobra.Group{ - ID: cloud.GroupCloud, - Title: "Trivy Cloud Commands", - }, ) rootCmd.SetCompletionCommandGroupID(groupUtility) rootCmd.SetHelpCommandGroupID(groupUtility) @@ -107,9 +102,6 @@ func NewApp() *cobra.Command { NewCleanCommand(globalFlags), NewRegistryCommand(globalFlags), NewVEXCommand(globalFlags), - NewLoginCommand(globalFlags), - NewLogoutCommand(), - NewCloudCommand(), ) if plugins := loadPluginCommands(); len(plugins) > 0 { @@ -217,7 +209,7 @@ func NewRootCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { // Initialize logger log.InitLogger(opts.Debug, opts.Quiet) - return cloud.CheckTrivyCloudStatus(cmd) + return nil }, RunE: func(cmd *cobra.Command, args []string) error { flags := flag.Flags{globalFlags} @@ -276,6 +268,7 @@ func NewImageCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { flag.NewScanFlagGroup(), flag.NewSecretFlagGroup(), flag.NewVulnerabilityFlagGroup(), + flag.NewProFlagGroup(), } cmd := &cobra.Command{ @@ -358,6 +351,7 @@ func NewFilesystemCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { flag.NewScanFlagGroup(), flag.NewSecretFlagGroup(), flag.NewVulnerabilityFlagGroup(), + flag.NewProFlagGroup(), } cmd := &cobra.Command{ @@ -424,6 +418,7 @@ func NewRootfsCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { flag.NewScanFlagGroup(), flag.NewSecretFlagGroup(), flag.NewVulnerabilityFlagGroup(), + flag.NewProFlagGroup(), } cmd := &cobra.Command{ @@ -489,6 +484,7 @@ func NewRepositoryCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { flag.NewSecretFlagGroup(), flag.NewVulnerabilityFlagGroup(), flag.NewRepoFlagGroup(), + flag.NewProFlagGroup(), } cmd := &cobra.Command{ @@ -711,6 +707,7 @@ func NewConfigCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { flag.NewModuleFlagGroup(), flag.NewRegistryFlagGroup(), flag.NewRegoFlagGroup(), + flag.NewProFlagGroup(), &flag.K8sFlagGroup{ // Keep only --k8s-version flag and disable others K8sVersion: flag.K8sVersionFlag.Clone(), @@ -1104,6 +1101,7 @@ func NewVMCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { flag.NewScanFlagGroup(), flag.NewSecretFlagGroup(), flag.NewVulnerabilityFlagGroup(), + flag.NewProFlagGroup(), &flag.AWSFlagGroup{ Region: &flag.Flag[string]{ Name: "aws-region", @@ -1422,149 +1420,6 @@ func NewVEXCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { return cmd } -func NewLoginCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { - loginFlags := &flag.Flags{ - globalFlags, - flag.NewCloudFlagGroup(), - } - - loginCmd := &cobra.Command{ - Use: "login [flags]", - Short: "Log in to the Trivy Cloud platform", - Long: "Log in to the Trivy Cloud platform to enable scanning of images and repositories in the cloud using the token retrieved from the Trivy Cloud platform", - GroupID: cloud.GroupCloud, - Args: cobra.NoArgs, - Example: ` # Log in to the Trivy Cloud platform - $ trivy login --token `, - PreRunE: func(cmd *cobra.Command, _ []string) error { - if err := loginFlags.Bind(cmd); err != nil { - return xerrors.Errorf("flag bind error: %w", err) - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - if err := loginFlags.Bind(cmd); err != nil { - return xerrors.Errorf("flag bind error: %w", err) - } - cloudOptions, err := loginFlags.ToOptions(args) - if err != nil { - return xerrors.Errorf("flag error: %w", err) - } - return cloud.Login(cmd.Context(), cloudOptions) - }, - } - - loginFlags.AddFlags(loginCmd) - loginCmd.SetFlagErrorFunc(flagErrorFunc) - - return loginCmd -} - -func NewLogoutCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "logout", - Short: "Log out of Trivy Cloud platform", - GroupID: cloud.GroupCloud, - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - return cloud.Logout() - }, - } - - return cmd -} - -func NewCloudCommand() *cobra.Command { - cloudCmd := &cobra.Command{ - Use: "cloud subcommand", - Short: "Control Trivy Cloud platform integration settings", - GroupID: cloud.GroupCloud, - } - - // add the group the sub commands so they don't check the login status - cloudCmd.AddGroup(&cobra.Group{ - ID: cloud.GroupCloud, - Title: "Trivy Cloud Commands", - }) - - configCmd := &cobra.Command{ - Use: "config subcommand", - Short: "Control Trivy Cloud configuration", - GroupID: cloud.GroupCloud, - } - - configCmd.AddGroup(&cobra.Group{ - ID: cloud.GroupCloud, - Title: "Trivy Cloud Configuration Commands", - }) - - configCmd.AddCommand( - &cobra.Command{ - Use: "edit", - Short: "Edit Trivy Cloud configuration", - Long: "Edit Trivy Cloud platform configuration in the default editor specified in the EDITOR environment variable", - GroupID: cloud.GroupCloud, - RunE: func(_ *cobra.Command, _ []string) error { - return cloud.EditConfig() - }, - }, - &cobra.Command{ - Use: "list", - Short: "List Trivy Cloud configuration", - Long: "List Trivy Cloud platform configuration in human readable format", - GroupID: cloud.GroupCloud, - RunE: func(_ *cobra.Command, _ []string) error { - return cloud.ListConfig() - }, - }, - &cobra.Command{ - Use: "set [setting] [value]", - Short: "Set Trivy Cloud configuration", - Long: `Set a Trivy Cloud platform setting - -Available config settings can be viewed by using the ` + "`trivy cloud config list`" + ` command`, - Example: ` $ trivy cloud config set server.scanning.enabled true - $ trivy cloud config set server.scanning.upload-results false`, - Args: cobra.ExactArgs(2), - GroupID: cloud.GroupCloud, - RunE: func(_ *cobra.Command, args []string) error { - return cloud.SetConfig(args[0], args[1]) - }, - }, - &cobra.Command{ - Use: "unset [setting]", - Short: "Unset Trivy Cloud configuration", - Long: `Unset a Trivy Cloud platform configuration and return it to the default setting - -Available config settings can be viewed by using the ` + "`trivy cloud config list`" + ` command`, - Example: ` $ trivy cloud config unset server.scanning.enabled - $ trivy cloud config unset server.scanning.upload-results`, - Args: cobra.ExactArgs(1), - GroupID: cloud.GroupCloud, - RunE: func(_ *cobra.Command, args []string) error { - return cloud.UnsetConfig(args[0]) - }, - }, - &cobra.Command{ - Use: "get [setting]", - Short: "Get Trivy Cloud configuration", - Long: `Get a Trivy Cloud platform configuration - -Available config settings can be viewed by using the ` + "`trivy cloud config list`" + ` command`, - Example: ` $ trivy cloud config get server.scanning.enabled - $ trivy cloud config get server.scanning.upload-results`, - Args: cobra.ExactArgs(1), - GroupID: cloud.GroupCloud, - RunE: func(_ *cobra.Command, args []string) error { - return cloud.GetConfig(args[0]) - }, - }, - ) - cloudCmd.AddCommand(configCmd) - - return cloudCmd -} - func NewVersionCommand(globalFlags *flag.GlobalFlagGroup) *cobra.Command { var versionFormat string cmd := &cobra.Command{ diff --git a/pkg/commands/artifact/run.go b/pkg/commands/artifact/run.go index 5c0eb2c5e9f2..be76717f621f 100644 --- a/pkg/commands/artifact/run.go +++ b/pkg/commands/artifact/run.go @@ -15,6 +15,7 @@ import ( "github.com/aquasecurity/trivy/pkg/cache" "github.com/aquasecurity/trivy/pkg/commands/operation" + "github.com/aquasecurity/trivy/pkg/commands/pro" "github.com/aquasecurity/trivy/pkg/db" "github.com/aquasecurity/trivy/pkg/extension" "github.com/aquasecurity/trivy/pkg/fanal/analyzer" @@ -376,6 +377,11 @@ func Run(ctx context.Context, opts flag.Options, targetKind TargetKind) (err err ctx, cancel := context.WithTimeout(ctx, opts.Timeout) defer cancel() + if err := pro.UpdateOptsForProIntegration(ctx, &opts); err != nil { + // log failure but continue with the scan + log.Error("failed to check Trivy Pro integration", "error", err) + } + if opts.GenerateDefaultConfig { log.Info("Writing the default config to trivy-default.yaml...") diff --git a/pkg/commands/cloud/run.go b/pkg/commands/cloud/run.go deleted file mode 100644 index 184574a142ed..000000000000 --- a/pkg/commands/cloud/run.go +++ /dev/null @@ -1,120 +0,0 @@ -package cloud - -import ( - "context" - "fmt" - "os" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "github.com/aquasecurity/trivy/pkg/cloud" - "github.com/aquasecurity/trivy/pkg/cloud/hooks" - "github.com/aquasecurity/trivy/pkg/extension" - "github.com/aquasecurity/trivy/pkg/flag" - "github.com/aquasecurity/trivy/pkg/log" -) - -const GroupCloud = "cloud" - -// Login performs a login to the Trivy Cloud Server service using the provided credentials. -func Login(ctx context.Context, opts flag.Options) error { - creds := opts.CloudOptions.LoginCredentials - if creds.Token == "" { - return xerrors.New("token is required for Trivy Cloud login") - } - if opts.CloudOptions.TrivyServerUrl == "" { - return xerrors.New("trivy server url is required for Trivy Cloud login") - } - if opts.CloudOptions.ApiUrl == "" { - return xerrors.New("api url is required for Trivy Cloud login") - } - - // load the existing config or get the default - cloudConfig, err := cloud.Load() - if err != nil { - return xerrors.Errorf("failed to load Trivy Cloud config: %w", err) - } - cloudConfig.Token = creds.Token - cloudConfig.Server.URL = opts.CloudOptions.TrivyServerUrl - cloudConfig.Api.URL = opts.CloudOptions.ApiUrl - - if err := cloudConfig.Verify(ctx); err != nil { - return xerrors.Errorf("failed to verify Trivy Cloud config: %w", err) - } - - if err := cloudConfig.Save(); err != nil { - return xerrors.Errorf("failed to save Trivy Cloud config: %w", err) - } - - log.WithPrefix(log.PrefixCloud).Info("Trivy Cloud login successful") - return nil -} - -// Logout removes the Trivy cloud configuration from both keychain and config file. -func Logout() error { - if err := cloud.Clear(); err != nil { - return xerrors.Errorf("failed to clear Trivy Cloud configuration: %w", err) - } - - log.WithPrefix(log.PrefixCloud).Info("Logged out of Trivy cloud and removed configuration") - return nil -} - -// CheckTrivyCloudStatus checks if the Trivy Cloud configuration file exists and verifies the token. -// If the token is valid, it sets the environment variables TRIVY_SERVER and TRIVY_TOKEN. -func CheckTrivyCloudStatus(cmd *cobra.Command) error { - if cmd.GroupID == GroupCloud { - return nil - } - - logger := log.WithPrefix(log.PrefixCloud) - cloudConfig, err := cloud.Load() - if err != nil { - logger.Error("Failed to load Trivy Cloud config file", log.Err(err)) - return nil - } - - if cloudConfig != nil && cloudConfig.Verify(cmd.Context()) == nil { - logger.Info("Trivy cloud is logged in") - if cloudConfig.Server.Scanning.Enabled { - logger.Info("Trivy Cloud server scanning is enabled") - os.Setenv("TRIVY_SERVER", cloudConfig.Server.URL) - os.Setenv("TRIVY_TOKEN_HEADER", "Authorization") - os.Setenv("TRIVY_TOKEN", fmt.Sprintf("Bearer %s", cloudConfig.Token)) - } - - if cloudConfig.Server.Scanning.UploadResults { - logger.Info("Trivy Cloud results upload is enabled") - // add hook to upload the results to Trivy Cloud - resultHook := hooks.NewResultsHook(cloudConfig) - extension.RegisterHook(resultHook) - } - } - - return nil -} - -func ListConfig() error { - return cloud.ListConfig() -} - -func EditConfig() error { - return cloud.OpenConfigForEditing() -} - -func SetConfig(attribute string, value any) error { - return cloud.Set(attribute, value) -} - -func UnsetConfig(attribute string) error { - return cloud.Unset(attribute) -} -func GetConfig(attribute string) error { - value, err := cloud.Get(attribute) - if err != nil { - return xerrors.Errorf("failed to get Trivy Cloud config: %w", err) - } - fmt.Println(value) - return nil -} diff --git a/pkg/commands/cloud/run_test.go b/pkg/commands/cloud/run_test.go deleted file mode 100644 index 5c487aa76539..000000000000 --- a/pkg/commands/cloud/run_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package cloud - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zalando/go-keyring" - - "github.com/aquasecurity/trivy/pkg/cloud" - "github.com/aquasecurity/trivy/pkg/flag" -) - -func TestLogout(t *testing.T) { - tests := []struct { - name string - createConfigFile bool - }{ - { - name: "successful logout when the config file exists", - createConfigFile: true, - }, - { - name: "successful logout when the config file does not exist", - createConfigFile: false, - }, - } - - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - defer keyring.DeleteAll(cloud.ServiceName) - defer cloud.Clear() - cloud.Clear() - - if tt.createConfigFile { - config := &cloud.Config{ - Server: cloud.Server{ - URL: "https://example.com", - }, - Api: cloud.Api{ - URL: "https://api.example.com", - }, - } - err := config.Save() - require.NoError(t, err) - } - - err := Logout() - require.NoError(t, err) - }) - } -} - -func TestLogin(t *testing.T) { - tests := []struct { - name string - token string - serverResponse int - wantErr string - }{ - { - name: "successful login with valid token", - token: "valid-token-123", - serverResponse: http.StatusOK, - }, - { - name: "login fails with empty token", - token: "", - serverResponse: http.StatusOK, - wantErr: "token is required for Trivy Cloud login", - }, - { - name: "login fails with server error", - token: "valid-token-123", - serverResponse: http.StatusUnauthorized, - wantErr: "failed to verify token: received status code 401", - }, - { - name: "login fails with server internal error", - token: "valid-token-123", - serverResponse: http.StatusInternalServerError, - wantErr: "failed to verify token: received status code 500", - }, - } - - tempDir := t.TempDir() - t.Setenv("XDG_DATA_HOME", tempDir) - - keyring.MockInit() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - defer keyring.DeleteAll(cloud.ServiceName) - - defer cloud.Clear() - cloud.Clear() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/verify", r.URL.Path) - - if tt.token != "" { - expectedAuth := "Bearer " + tt.token - assert.Equal(t, expectedAuth, r.Header.Get("Authorization")) - } - - w.WriteHeader(tt.serverResponse) - })) - defer server.Close() - - opts := flag.Options{ - CloudOptions: flag.CloudOptions{ - LoginCredentials: flag.CloudLoginCredentials{ - Token: tt.token, - }, - ApiUrl: server.URL + "/api", - TrivyServerUrl: server.URL, - }, - } - - ctx := context.Background() - err := Login(ctx, opts) - - if tt.wantErr != "" { - require.ErrorContains(t, err, tt.wantErr) - return - } - - require.NoError(t, err) - - config, err := cloud.Load() - require.NoError(t, err) - require.Equal(t, tt.token, config.Token) - require.Equal(t, server.URL, config.Server.URL) - require.Equal(t, server.URL+"/api", config.Api.URL) - }) - } -} diff --git a/pkg/commands/pro/run.go b/pkg/commands/pro/run.go new file mode 100644 index 000000000000..228e89c42bb4 --- /dev/null +++ b/pkg/commands/pro/run.go @@ -0,0 +1,55 @@ +package pro + +import ( + "context" + "fmt" + + "golang.org/x/xerrors" + + "github.com/aquasecurity/trivy/pkg/extension" + "github.com/aquasecurity/trivy/pkg/flag" + "github.com/aquasecurity/trivy/pkg/log" + "github.com/aquasecurity/trivy/pkg/pro" + "github.com/aquasecurity/trivy/pkg/pro/hooks" + "github.com/aquasecurity/trivy/pkg/types" +) + +// UpdateOptsForProIntegration checks if the Trivy Pro integration is enabled and configures the options accordingly +// if there are variables that are already set that would cause a conflict, we return an error. +// if the token is not provided, we don't need to check the integration and can return early. +func UpdateOptsForProIntegration(ctx context.Context, opts *flag.Options) error { + if opts.ProOptions.ProToken == "" { + return nil + } + + logger := log.WithPrefix(log.PrefixCloud) + accessToken, err := pro.GetAccessToken(ctx, *opts) + if err != nil { + return xerrors.Errorf("failed to get access token for Trivy Pro: %w", err) + } + + if opts.ProOptions.UseServerSideScanning { + // ensure that the server address hasn't been already set, this would be an unacceptable config conflict. + if opts.ServerAddr != "" && opts.ServerAddr != opts.ProOptions.TrivyServerURL { + return xerrors.Errorf("server-side scanning is enabled, but server address is already set to %s", opts.ServerAddr) + } + + logger.Debug("Using server-side scanning for Trivy Pro, updating opts") + opts.ServerAddr = opts.ProOptions.TrivyServerURL + opts.CustomHeaders.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + } + + if opts.ProOptions.SecretConfig && opts.Scanners.Enabled(types.SecretScanner) { + if err := pro.GetConfigs(ctx, opts, accessToken); err != nil { + return xerrors.Errorf("failed to download configs: %w", err) + } + } + + // if uploading results we need to register a report hook with the required details + if opts.ProOptions.UploadResults { + reportHook := hooks.NewReportHook(opts.ProOptions.ApiURL, accessToken) + extension.RegisterHook(reportHook) + } + + return nil +} diff --git a/pkg/commands/pro/run_test.go b/pkg/commands/pro/run_test.go new file mode 100644 index 000000000000..5183223d7ae5 --- /dev/null +++ b/pkg/commands/pro/run_test.go @@ -0,0 +1,148 @@ +package pro + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/flag" + "github.com/aquasecurity/trivy/pkg/types" +) + +type mockCloudServer struct { + server *httptest.Server + configAvailable bool +} + +func (m *mockCloudServer) Start() { + m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer valid-cloud-token" && r.Header.Get("Authorization") != "Bearer test-access-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Path == "/api-keys/access-tokens" { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"token": "test-access-token"}`)) + return + } + + if r.URL.Path == "/configs/secrets/secret-config.yaml" { + if !m.configAvailable { + w.WriteHeader(http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer test-access-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"content": {"key": "value"}}`)) + return + } + + w.WriteHeader(http.StatusNotFound) + })) +} + +func (m *mockCloudServer) Close() { + m.server.Close() +} + +func TestUpdateOptsForCloudIntegration(t *testing.T) { + mockServer := &mockCloudServer{} + mockServer.Start() + defer mockServer.Close() + + tests := []struct { + name string + opts *flag.Options + configAvailable bool + errorContains string + }{ + { + name: "valid token and config to download", + opts: &flag.Options{ + ProOptions: flag.ProOptions{ + ProToken: "valid-cloud-token", + ApiURL: mockServer.server.URL, + TrivyServerURL: mockServer.server.URL, + SecretConfig: true, + }, + ScanOptions: flag.ScanOptions{ + Scanners: types.Scanners{types.SecretScanner}, + }, + }, + configAvailable: true, + }, + { + name: "valid token but config not requested", + opts: &flag.Options{ + ProOptions: flag.ProOptions{ + ProToken: "valid-cloud-token", + ApiURL: mockServer.server.URL, + TrivyServerURL: mockServer.server.URL, + SecretConfig: false, + }, + ScanOptions: flag.ScanOptions{ + Scanners: types.Scanners{types.SecretScanner}, + }, + }, + configAvailable: true, + }, + { + name: "valid token but config not available", + opts: &flag.Options{ + ProOptions: flag.ProOptions{ + ProToken: "valid-cloud-token", + ApiURL: mockServer.server.URL, + TrivyServerURL: mockServer.server.URL, + SecretConfig: false, + }, + }, + configAvailable: false, + }, + { + name: "invalid token 401 status code", + opts: &flag.Options{ + ProOptions: flag.ProOptions{ + ProToken: "invalid-token", + ApiURL: mockServer.server.URL, + TrivyServerURL: mockServer.server.URL, + SecretConfig: false, + }, + ScanOptions: flag.ScanOptions{ + Scanners: types.Scanners{types.SecretScanner}, + }, + }, + configAvailable: true, + errorContains: "failed to get access token for Trivy Pro", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tempDir) + mockServer.configAvailable = tt.configAvailable + + err := UpdateOptsForProIntegration(t.Context(), tt.opts) + + if tt.errorContains != "" { + require.ErrorContains(t, err, tt.errorContains) + return + } + + require.NoError(t, err) + + if tt.opts.ProOptions.SecretConfig && tt.opts.ScanOptions.Scanners.Enabled(types.SecretScanner) { + assert.NotEmpty(t, tt.opts.SecretOptions.SecretConfigPath) + assert.FileExists(t, tt.opts.SecretOptions.SecretConfigPath) + } else { + assert.Empty(t, tt.opts.SecretOptions.SecretConfigPath) + } + }) + } +} diff --git a/pkg/flag/cloud_flags.go b/pkg/flag/cloud_flags.go deleted file mode 100644 index 0e0b1a5b729f..000000000000 --- a/pkg/flag/cloud_flags.go +++ /dev/null @@ -1,75 +0,0 @@ -package flag - -import "github.com/aquasecurity/trivy/pkg/cloud" - -var ( - CloudTokenFlag = Flag[string]{ - Name: "token", - ConfigName: "cloud.token", - Usage: "Token used to athenticate with Trivy Cloud platform", - } - - CloudApiUrlFlag = Flag[string]{ - Name: "api-url", - ConfigName: "cloud.api-url", - Default: cloud.DefaultApiUrl, - Usage: "API URL for Trivy Cloud platform", - } - - CloudTrivyServerUrlFlag = Flag[string]{ - Name: "trivy-server-url", - ConfigName: "cloud.trivy_server_url", - Default: cloud.DefaultTrivyServerUrl, - Usage: "Trivy Server URL for Trivy Cloud platform", - } -) - -type CloudFlagGroup struct { - CloudToken *Flag[string] - CloudApiUrl *Flag[string] - CloudTrivyServerUrl *Flag[string] -} - -func NewCloudFlagGroup() *CloudFlagGroup { - return &CloudFlagGroup{ - CloudToken: CloudTokenFlag.Clone(), - CloudApiUrl: CloudApiUrlFlag.Clone(), - CloudTrivyServerUrl: CloudTrivyServerUrlFlag.Clone(), - } -} - -func (f *CloudFlagGroup) Name() string { - return "Trivy Cloud" -} - -func (f *CloudFlagGroup) Flags() []Flagger { - return []Flagger{ - f.CloudToken, - f.CloudApiUrl, - f.CloudTrivyServerUrl, - } -} - -// CloudLoginCredentials is the credentials used to authenticate with Trivy Cloud platform -// In the future this would likely have more information stored for refresh tokens, etc -type CloudLoginCredentials struct { - Token string -} - -type CloudOptions struct { - LoginCredentials CloudLoginCredentials - ApiUrl string - TrivyServerUrl string -} - -// ToOptions converts the flags to options -func (f *CloudFlagGroup) ToOptions(opts *Options) error { - opts.CloudOptions = CloudOptions{ - LoginCredentials: CloudLoginCredentials{ - Token: f.CloudToken.Value(), - }, - ApiUrl: f.CloudApiUrl.Value(), - TrivyServerUrl: f.CloudTrivyServerUrl.Value(), - } - return nil -} diff --git a/pkg/flag/options.go b/pkg/flag/options.go index b3c325256d1b..62eef386c8f0 100644 --- a/pkg/flag/options.go +++ b/pkg/flag/options.go @@ -406,7 +406,7 @@ type Options struct { RemoteOptions RepoOptions ReportOptions - CloudOptions + ProOptions ScanOptions SecretOptions VulnerabilityOptions diff --git a/pkg/flag/pro_flags.go b/pkg/flag/pro_flags.go new file mode 100644 index 000000000000..e4bde7b0cdf4 --- /dev/null +++ b/pkg/flag/pro_flags.go @@ -0,0 +1,118 @@ +package flag + +const ( + DefaultApiURL = "https://api.trivy.dev" + DefaultTrivyServerURL = "https://scan.trivy.dev" +) + +var ( + ProTokenFlag = Flag[string]{ + Name: "pro-token", + ConfigName: "pro.token", + Usage: "Token used to athenticate with Trivy Pro platform", + } + + ProAPIURLFlag = Flag[string]{ + Name: "pro-api-url", + ConfigName: "pro.api-url", + Default: DefaultApiURL, + Usage: "API URL for Trivy Pro platform, requires the token to be provided to have an effect", + TelemetrySafe: true, + } + + ProTrivyServerURLFlag = Flag[string]{ + Name: "pro-trivy-server-url", + ConfigName: "pro.trivy-server-url", + Default: DefaultTrivyServerURL, + Usage: "Trivy Server URL for Trivy Pro platform, requires the token to be provided to have an effect", + TelemetrySafe: true, + } + + ProUploadResultsFlag = Flag[bool]{ + Name: "pro-upload-results", + ConfigName: "pro.upload-results", + Default: true, + Usage: "Upload results to Trivy Pro platform, requires the token to be provided to have an effect", + TelemetrySafe: true, + } + + ProSecretConfigFlag = Flag[bool]{ + Name: "pro-use-secret-config", + ConfigName: "pro.use-secret-config", + Default: true, + Usage: "Use secret configurations from Trivy Pro platform, requires the token to be provided to have an effect", + TelemetrySafe: true, + } + + ProUseServerSideScanningFlag = Flag[bool]{ + Name: "pro-server-scanning", + ConfigName: "pro.server-scanning", + Default: true, + Usage: "Use server-side image scanning in Trivy Pro platform, requires the token to be provided to have an effect", + TelemetrySafe: true, + } +) + +type ProFlagGroup struct { + ProToken *Flag[string] + ProApiURL *Flag[string] + ProTrivyServerURL *Flag[string] + ProUploadResults *Flag[bool] + ProSecretConfig *Flag[bool] + + ProUseServerSideScanning *Flag[bool] +} + +func NewProFlagGroup() *ProFlagGroup { + return &ProFlagGroup{ + ProToken: ProTokenFlag.Clone(), + ProApiURL: ProAPIURLFlag.Clone(), + ProTrivyServerURL: ProTrivyServerURLFlag.Clone(), + ProUploadResults: ProUploadResultsFlag.Clone(), + ProSecretConfig: ProSecretConfigFlag.Clone(), + ProUseServerSideScanning: ProUseServerSideScanningFlag.Clone(), + } +} + +func (f *ProFlagGroup) Name() string { + return "Trivy Pro" +} + +func (f *ProFlagGroup) Flags() []Flagger { + return []Flagger{ + f.ProToken, + f.ProApiURL, + f.ProTrivyServerURL, + f.ProUploadResults, + f.ProSecretConfig, + f.ProUseServerSideScanning, + } +} + +// ProLoginCredentials is the credentials used to authenticate with Trivy Pro platform +// In the future this would likely have more information stored for refresh tokens, etc +type ProLoginCredentials struct { + Token string +} + +type ProOptions struct { + ProToken string + ApiURL string + TrivyServerURL string + UploadResults bool + SecretConfig bool + UseServerSideScanning bool +} + +// ToOptions converts the flags to options +func (f *ProFlagGroup) ToOptions(opts *Options) error { + opts.ProOptions = ProOptions{ + ProToken: f.ProToken.Value(), + ApiURL: f.ProApiURL.Value(), + TrivyServerURL: f.ProTrivyServerURL.Value(), + UploadResults: f.ProUploadResults.Value(), + SecretConfig: f.ProSecretConfig.Value(), + UseServerSideScanning: f.ProUseServerSideScanning.Value(), + } + return nil +} diff --git a/pkg/pro/config.go b/pkg/pro/config.go new file mode 100644 index 000000000000..646b35705eea --- /dev/null +++ b/pkg/pro/config.go @@ -0,0 +1,127 @@ +package pro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + "golang.org/x/xerrors" + "gopkg.in/yaml.v3" + + "github.com/aquasecurity/trivy/pkg/flag" + "github.com/aquasecurity/trivy/pkg/log" + "github.com/aquasecurity/trivy/pkg/types" + "github.com/aquasecurity/trivy/pkg/utils/fsutils" + xhttp "github.com/aquasecurity/trivy/pkg/x/http" +) + +type ConfigType string + +const ( + // Additional config types can be added here - in future pipeline rego etc + ConfigTypeSecret ConfigType = "secret" +) + +const ( + SecretConfigPath = "/configs/secrets/secret-config.yaml" + configCacheTTL = time.Hour +) + +var configPaths = map[ConfigType]string{ + ConfigTypeSecret: SecretConfigPath, +} + +type cloudConfigResponse struct { + Content map[string]any `json:"content"` +} + +func GetConfigs(ctx context.Context, opts *flag.Options, accessToken string) error { + logger := log.WithPrefix(log.PrefixCloud) + client := xhttp.ClientWithContext(ctx) + + if opts.ProOptions.SecretConfig && opts.Scanners.Enabled(types.SecretScanner) { + if opts.SecretOptions.SecretConfigPath != "" { + logger.Warn("Secret config path already set", log.FilePath(opts.SecretOptions.SecretConfigPath)) + return nil + } + + configPath, err := getConfigFromTrivyPro(ctx, client, opts, accessToken, ConfigTypeSecret) + if err != nil { + return xerrors.Errorf("failed to get secret config: %w", err) + } + if configPath != "" { + opts.SecretOptions.SecretConfigPath = configPath + } + } + return nil +} + +// getConfigFromTrivyPro downloads a config from Trivy Pro and saves it to a file +// it returns the path to the config file if it was downloaded successfully, otherwise it returns an error +func getConfigFromTrivyPro(ctx context.Context, client *http.Client, opts *flag.Options, accessToken string, configType ConfigType) (string, error) { + logger := log.WithPrefix(log.PrefixCloud).With("configType", configType) + configTypeStr := string(configType) + configDir := filepath.Join(fsutils.TrivyHomeDir(), "cloud", configTypeStr) + if err := os.MkdirAll(configDir, os.ModePerm); err != nil { + return "", xerrors.Errorf("failed to create cloud config directory: %w", err) + } + + configFilename := filepath.Join(configDir, "config.yaml") + // Return cached config if it was updated within the last hour + if stat, err := os.Stat(configFilename); err == nil && stat.ModTime().After(time.Now().Add(-configCacheTTL)) { + logger.Debug("Config found in cache", log.FilePath(configFilename)) + return configFilename, nil + } + + logger.Debug("Config not found in cache", log.FilePath(configFilename)) + configPath, ok := configPaths[configType] + if !ok { + return "", xerrors.Errorf("unknown config type: %s", configType) + } + configUrl, err := url.JoinPath(opts.ProOptions.TrivyServerURL, configPath) + if err != nil { + return "", xerrors.Errorf("failed to join API URL and config path: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, configUrl, http.NoBody) + if err != nil { + return "", xerrors.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + resp, err := client.Do(req) + if err != nil { + return "", xerrors.Errorf("failed to get config: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + logger.Debug("Config not found in Trivy Pro", log.String("configType", string(configType))) + return "", nil + } + return "", xerrors.Errorf("failed to get config: received status code %d", resp.StatusCode) + } + + var response cloudConfigResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return "", xerrors.Errorf("failed to decode config: %w", err) + } + + if response.Content == nil { + return "", xerrors.Errorf("config content is empty") + } + + configContentBytes, err := yaml.Marshal(response.Content) + if err != nil { + return "", xerrors.Errorf("failed to marshal config content: %w", err) + } + + if err := os.WriteFile(configFilename, configContentBytes, 0o600); err != nil { + return "", xerrors.Errorf("failed to write config: %w", err) + } + + return configFilename, nil +} diff --git a/pkg/pro/config_test.go b/pkg/pro/config_test.go new file mode 100644 index 000000000000..7dbec9f01ff1 --- /dev/null +++ b/pkg/pro/config_test.go @@ -0,0 +1,71 @@ +package pro + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/flag" + "github.com/aquasecurity/trivy/pkg/types" +) + +func TestGetConfigs_Secrets(t *testing.T) { + + mockServer := &mockApiServer{} + mockServer.Start() + defer mockServer.Close() + + tests := []struct { + name string + accessToken string + serverURL string + errorContains string + }{ + { + name: "incorrect api token", + accessToken: "invalid-token", + serverURL: mockServer.server.URL, + errorContains: "failed to get secret config", + }, + { + name: "config doesn't exist", + accessToken: "valid-token", + serverURL: mockServer.server.URL + "/nonexistent", + errorContains: "failed to get secret config", + }, + { + name: "simple config that exists", + accessToken: "valid-token", + serverURL: mockServer.server.URL, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tempDir) + + opts := &flag.Options{ + ProOptions: flag.ProOptions{ + SecretConfig: true, + TrivyServerURL: tt.serverURL, + }, + ScanOptions: flag.ScanOptions{ + Scanners: types.Scanners{types.SecretScanner}, + }, + } + + err := GetConfigs(context.Background(), opts, tt.accessToken) + + if tt.errorContains != "" { + require.ErrorContains(t, err, tt.errorContains) + return + } + require.NoError(t, err) + require.NotEmpty(t, opts.SecretOptions.SecretConfigPath) + assert.FileExists(t, opts.SecretOptions.SecretConfigPath) + }) + } +} diff --git a/pkg/cloud/hooks/report_hook.go b/pkg/pro/hooks/report_hook.go similarity index 72% rename from pkg/cloud/hooks/report_hook.go rename to pkg/pro/hooks/report_hook.go index 495a36c5ad07..fcd2e650f632 100644 --- a/pkg/cloud/hooks/report_hook.go +++ b/pkg/pro/hooks/report_hook.go @@ -10,7 +10,6 @@ import ( "golang.org/x/xerrors" - "github.com/aquasecurity/trivy/pkg/cloud" "github.com/aquasecurity/trivy/pkg/flag" "github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/types" @@ -21,32 +20,34 @@ const ( presignedUploadUrl = "/trivy-reports/upload-url" ) -type CloudPlatformResultsHook struct { +type ReportHook struct { name string - cloudConfig *cloud.Config + apiUrl string + accessToken string client *http.Client logger *log.Logger } -func NewResultsHook(cloudCfg *cloud.Config) *CloudPlatformResultsHook { - return &CloudPlatformResultsHook{ - name: "Trivy Cloud Results Hook", - cloudConfig: cloudCfg, +func NewReportHook(apiUrl, accessToken string) *ReportHook { + return &ReportHook{ + name: "Trivy Pro Results Hook", + apiUrl: apiUrl, + accessToken: accessToken, client: xhttp.Client(), logger: log.WithPrefix(log.PrefixCloud), } } -func (h *CloudPlatformResultsHook) Name() string { +func (h *ReportHook) Name() string { return h.name } // PreReport is not going go to be called so we return nil -func (h *CloudPlatformResultsHook) PreReport(_ context.Context, _ *types.Report, _ flag.Options) error { +func (h *ReportHook) PreReport(_ context.Context, _ *types.Report, _ flag.Options) error { return nil } -func (h *CloudPlatformResultsHook) PostReport(ctx context.Context, report *types.Report, _ flag.Options) error { +func (h *ReportHook) PostReport(ctx context.Context, report *types.Report, _ flag.Options) error { h.logger.Debug("PostReport called with report") jsonReport, err := json.MarshalIndent(report, "", " ") if err != nil { @@ -56,7 +57,7 @@ func (h *CloudPlatformResultsHook) PostReport(ctx context.Context, report *types return h.uploadResults(ctx, jsonReport) } -func (h *CloudPlatformResultsHook) uploadResults(ctx context.Context, jsonReport []byte) error { +func (h *ReportHook) uploadResults(ctx context.Context, jsonReport []byte) error { uploadUrl, err := h.getPresignedUploadUrl(ctx) if err != nil { return fmt.Errorf("failed to get presigned upload URL: %w", err) @@ -79,12 +80,12 @@ func (h *CloudPlatformResultsHook) uploadResults(ctx context.Context, jsonReport return fmt.Errorf("failed to upload results: received status code %d", resp.StatusCode) } - h.logger.Info("Report uploaded successfully to Trivy Cloud") + h.logger.Info("Report uploaded successfully to Trivy Pro") return nil } -func (h *CloudPlatformResultsHook) getPresignedUploadUrl(ctx context.Context) (string, error) { - uploadUrl, err := url.JoinPath(h.cloudConfig.Api.URL, presignedUploadUrl) +func (h *ReportHook) getPresignedUploadUrl(ctx context.Context) (string, error) { + uploadUrl, err := url.JoinPath(h.apiUrl, presignedUploadUrl) if err != nil { return "", fmt.Errorf("failed to join API URL and presigned upload URL: %w", err) } @@ -95,7 +96,7 @@ func (h *CloudPlatformResultsHook) getPresignedUploadUrl(ctx context.Context) (s return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+h.cloudConfig.Token) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", h.accessToken)) resp, err := h.client.Do(req) if err != nil { return "", fmt.Errorf("failed to get upload URL: %w", err) diff --git a/pkg/pro/hooks/report_hook_test.go b/pkg/pro/hooks/report_hook_test.go new file mode 100644 index 000000000000..c3d13d912497 --- /dev/null +++ b/pkg/pro/hooks/report_hook_test.go @@ -0,0 +1,317 @@ +package hooks + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/flag" + "github.com/aquasecurity/trivy/pkg/types" +) + +type mockReportServer struct { + server *httptest.Server + uploadURLRequested bool + reportUploaded bool + uploadedReport *types.Report + returnUnauthorized bool + returnInvalidJSON bool + failUpload bool + presignedUploadPath string +} + +func (m *mockReportServer) Start() { + m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == presignedUploadUrl { + m.handlePresignedURLRequest(w, r) + return + } + + if r.URL.Path == m.presignedUploadPath { + m.handleReportUpload(w, r) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + m.presignedUploadPath = "/upload-report" +} + +func (m *mockReportServer) handlePresignedURLRequest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if m.returnUnauthorized { + w.WriteHeader(http.StatusUnauthorized) + return + } + + m.uploadURLRequested = true + + if m.returnInvalidJSON { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`invalid json`)) + return + } + + uploadURL := m.server.URL + m.presignedUploadPath + response := map[string]string{ + "uploadUrl": uploadURL, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +func (m *mockReportServer) handleReportUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + return + } + + if m.failUpload { + w.WriteHeader(http.StatusInternalServerError) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var report types.Report + if err := json.Unmarshal(body, &report); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + m.reportUploaded = true + m.uploadedReport = &report + + w.WriteHeader(http.StatusOK) +} + +func (m *mockReportServer) Close() { + if m.server != nil { + m.server.Close() + } +} + +func TestReportHook_Name(t *testing.T) { + hook := NewReportHook("http://api.example.com", "test-token") + assert.Equal(t, "Trivy Pro Results Hook", hook.Name()) +} + +func TestReportHook_PreReport(t *testing.T) { + hook := NewReportHook("http://api.example.com", "test-token") + err := hook.PreReport(context.Background(), &types.Report{}, flag.Options{}) + assert.NoError(t, err) +} + +func TestReportHook_PostReport(t *testing.T) { + tests := []struct { + name string + report *types.Report + returnUnauthorized bool + returnInvalidJSON bool + failUpload bool + errorContains string + }{ + { + name: "successful upload", + report: &types.Report{ + ArtifactName: "test-artifact", + ArtifactType: ftypes.TypeContainerImage, + Results: types.Results{ + { + Target: "test-target", + Vulnerabilities: []types.DetectedVulnerability{ + { + VulnerabilityID: "CVE-2021-1234", + PkgName: "test-package", + PkgID: "test-package@1.0.0", + }, + }, + }, + }, + }, + }, + { + name: "empty report", + report: &types.Report{ + ArtifactName: "empty-artifact", + ArtifactType: ftypes.TypeContainerImage, + }, + }, + { + name: "invalid token 401 status code", + report: &types.Report{ + ArtifactName: "test-artifact", + }, + returnUnauthorized: true, + errorContains: "failed to get presigned upload URL", + }, + { + name: "unauthorized access", + report: &types.Report{ + ArtifactName: "test-artifact", + }, + returnUnauthorized: true, + errorContains: "failed to get presigned upload URL", + }, + { + name: "invalid json response", + report: &types.Report{ + ArtifactName: "test-artifact", + }, + returnInvalidJSON: true, + errorContains: "failed to decode upload URL response", + }, + { + name: "upload failure", + report: &types.Report{ + ArtifactName: "test-artifact", + }, + failUpload: true, + errorContains: "failed to upload results", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := &mockReportServer{ + returnUnauthorized: tt.returnUnauthorized, + returnInvalidJSON: tt.returnInvalidJSON, + failUpload: tt.failUpload, + } + mockServer.Start() + defer mockServer.Close() + + hook := NewReportHook(mockServer.server.URL, "test-token") + err := hook.PostReport(context.Background(), tt.report, flag.Options{}) + + if tt.errorContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.errorContains) + return + } + + require.NoError(t, err) + assert.True(t, mockServer.uploadURLRequested) + assert.True(t, mockServer.reportUploaded) + assert.Equal(t, tt.report.ArtifactName, mockServer.uploadedReport.ArtifactName) + }) + } +} + +func TestReportHook_uploadResults(t *testing.T) { + tests := []struct { + name string + jsonReport []byte + failUpload bool + errorContains string + }{ + { + name: "successful upload", + jsonReport: []byte(`{"artifactName": "test"}`), + }, + { + name: "upload failure", + jsonReport: []byte(`{"artifactName": "test"}`), + failUpload: true, + errorContains: "failed to upload results", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := &mockReportServer{ + failUpload: tt.failUpload, + } + mockServer.Start() + defer mockServer.Close() + + hook := NewReportHook(mockServer.server.URL, "test-token") + err := hook.uploadResults(context.Background(), tt.jsonReport) + + if tt.errorContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.errorContains) + return + } + + require.NoError(t, err) + assert.True(t, mockServer.reportUploaded) + }) + } +} + +func TestReportHook_getPresignedUploadUrl(t *testing.T) { + tests := []struct { + name string + returnUnauthorized bool + returnInvalidJSON bool + errorContains string + expectedURL string + }{ + { + name: "successful request", + }, + { + name: "unauthorized", + returnUnauthorized: true, + errorContains: "failed to get upload URL", + }, + { + name: "invalid json response", + returnInvalidJSON: true, + errorContains: "failed to decode upload URL response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockServer := &mockReportServer{ + returnUnauthorized: tt.returnUnauthorized, + returnInvalidJSON: tt.returnInvalidJSON, + } + mockServer.Start() + defer mockServer.Close() + + hook := NewReportHook(mockServer.server.URL, "test-token") + url, err := hook.getPresignedUploadUrl(context.Background()) + + if tt.errorContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.errorContains) + assert.Empty(t, url) + return + } + + require.NoError(t, err) + assert.Contains(t, url, mockServer.server.URL) + assert.Contains(t, url, "/upload-report") + }) + } +} diff --git a/pkg/pro/token.go b/pkg/pro/token.go new file mode 100644 index 000000000000..1028207af6ac --- /dev/null +++ b/pkg/pro/token.go @@ -0,0 +1,64 @@ +package pro + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "golang.org/x/xerrors" + + "github.com/aquasecurity/trivy/pkg/flag" + "github.com/aquasecurity/trivy/pkg/log" + xhttp "github.com/aquasecurity/trivy/pkg/x/http" +) + +type accessTokenResponse struct { + Token string `json:"token"` +} + +const ( + accessTokenPath = "/api-keys/access-tokens" +) + +func GetAccessToken(ctx context.Context, opts flag.Options) (string, error) { + if opts.ProOptions.ProToken == "" { + return "", xerrors.New("no pro token provided for getting access token from Trivy Pro") + } + + if opts.ProOptions.ApiURL == "" { + return "", xerrors.New("no API URL provided for getting access token from Trivy Pro") + } + + logger := log.WithPrefix(log.PrefixCloud) + + client := xhttp.Client() + u, err := url.JoinPath(opts.ProOptions.ApiURL, accessTokenPath) + if err != nil { + return "", xerrors.Errorf("failed to join server URL and token path: %w", err) + } + logger.Debug("Requesting access token from Trivy Pro", log.String("url", u)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, http.NoBody) + if err != nil { + return "", xerrors.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", opts.ProOptions.ProToken)) + resp, err := client.Do(req) + if err != nil { + return "", xerrors.Errorf("failed to get access token: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return "", xerrors.Errorf("failed to get access token from Trivy Pro: received status code %d", resp.StatusCode) + } + + var tokenResponse accessTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { + return "", xerrors.Errorf("failed to decode access token response: %w", err) + } + + logger.Debug("Created a new access token") + return tokenResponse.Token, nil +} diff --git a/pkg/pro/token_test.go b/pkg/pro/token_test.go new file mode 100644 index 000000000000..f375d09654f5 --- /dev/null +++ b/pkg/pro/token_test.go @@ -0,0 +1,100 @@ +package pro + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/flag" +) + +type mockApiServer struct { + server *httptest.Server +} + +func (m *mockApiServer) Start() { + m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer valid-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + // handle access token request + if r.URL.Path == accessTokenPath { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"token": "test-token"}`)) + } + if r.URL.Path == "/configs/secrets/secret-config.yaml" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"content": {"key": "value"}}`)) + } + })) +} + +func (m *mockApiServer) Close() { + m.server.Close() +} + +func TestGetAccessToken(t *testing.T) { + + mockServer := &mockApiServer{} + mockServer.Start() + defer mockServer.Close() + + tests := []struct { + name string + opts flag.Options + want string + expectedStatusCode int + errorContains string + }{ + { + name: "happy path", + opts: flag.Options{ + ProOptions: flag.ProOptions{ + ProToken: "valid-token", + ApiURL: mockServer.server.URL, + }, + }, + want: "test-token", + expectedStatusCode: http.StatusCreated, + }, + { + name: "no API URL", + opts: flag.Options{ + ProOptions: flag.ProOptions{ + ProToken: "valid-token", + ApiURL: "", + }, + }, + errorContains: "no API URL provided for getting access token from Trivy Pro", + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "invalid token", + opts: flag.Options{ + ProOptions: flag.ProOptions{ + ProToken: "invalid-token", + ApiURL: mockServer.server.URL, + }, + }, + errorContains: "failed to get access token: received status code 401", + expectedStatusCode: http.StatusUnauthorized, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetAccessToken(t.Context(), tt.opts) + + if tt.errorContains != "" { + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + + }) + } +}