From fd2bc91e133f846bc9f0910c19ac3be3fbfe4009 Mon Sep 17 00:00:00 2001 From: Owen Rumney Date: Fri, 27 Jun 2025 08:14:25 +0100 Subject: [PATCH] fix(cli): add some values to the telemetry call (#9056) --- docs/docs/advanced/telemetry-flags.md | 19 ++ docs/docs/advanced/telemetry.md | 24 ++- magefiles/docs.go | 105 +++++++---- mkdocs.yml | 1 + pkg/commands/artifact/run.go | 12 +- pkg/flag/aws_flags.go | 14 +- pkg/flag/global_flags.go | 42 +++-- pkg/flag/misconf_flags.go | 3 +- pkg/flag/options.go | 44 +++++ pkg/flag/package_flags.go | 19 +- pkg/flag/report_flags.go | 43 +++-- pkg/flag/scan_flags.go | 4 +- pkg/flag/vulnerability_flags.go | 14 +- pkg/notification/notice.go | 81 +++++--- pkg/notification/notice_test.go | 255 ++++++++++++++++++++------ pkg/notification/option.go | 37 ---- 16 files changed, 479 insertions(+), 238 deletions(-) create mode 100644 docs/docs/advanced/telemetry-flags.md delete mode 100644 pkg/notification/option.go diff --git a/docs/docs/advanced/telemetry-flags.md b/docs/docs/advanced/telemetry-flags.md new file mode 100644 index 0000000000..490bccd234 --- /dev/null +++ b/docs/docs/advanced/telemetry-flags.md @@ -0,0 +1,19 @@ +``` +--debug +--detection-priority +--format +--ignore-status +--include-dev-deps +--insecure +--list-all-pkgs +--misconfig-scanners +--pkg-relationships +--pkg-types +--quiet +--report +--scanners +--severity +--show-suppressed +--timeout +--vuln-severity-source +``` diff --git a/docs/docs/advanced/telemetry.md b/docs/docs/advanced/telemetry.md index 92763f8b69..e9e1056be9 100644 --- a/docs/docs/advanced/telemetry.md +++ b/docs/docs/advanced/telemetry.md @@ -1,24 +1,30 @@ # Usage Telemetry -Trivy collect anonymous usage data in order to help us improve the product. This document explains what is collected and how you can control it. +Trivy collects anonymous usage data in order to help us improve the product. This document explains what is collected and how you can control it. ## Data collected The following information could be collected: -- Environmental information - - Installation identifier - - Trivy version - - Operating system -- Scan - - Non-revealing scan options +- Environmental information: + - Installation identifier + - Trivy version + - Operating system +- Scan: + - Non-revealing scan options (see below for comprehensive list) + +### Captured scan options +The following flags will be included with their value: + +--8<-- "./docs/docs/advanced/telemetry-flags.md" + ## Privacy No personal information, scan results, or sensitive data is specifically collected. We take the following measures to ensure that: -- Installation identifier: one-way hash of machine fingerprint, resulting in opaque string. -- Scaner: any option that is user controlled is omitted (never collected). For example, file paths, image names, etc are never collected. +- Installation identifier: one-way hash of machine fingerprint, resulting in opaque ID. +- Scan: any option that is user-controlled is omitted (never collected). For example, file paths, image names, etc are never collected. Trivy is an Aqua Security product and adheres to the company's privacy policy: . diff --git a/magefiles/docs.go b/magefiles/docs.go index f37e0d5d04..57297ac5fd 100644 --- a/magefiles/docs.go +++ b/magefiles/docs.go @@ -7,13 +7,13 @@ import ( "fmt" "os" "slices" + "sort" "strings" - "github.com/spf13/cobra/doc" - "github.com/aquasecurity/trivy/pkg/commands" "github.com/aquasecurity/trivy/pkg/flag" "github.com/aquasecurity/trivy/pkg/log" + "github.com/spf13/cobra/doc" ) const ( @@ -35,50 +35,43 @@ func main() { os.Setenv("XDG_DATA_HOME", os.TempDir()) cmd := commands.NewApp() + allFlagGroups := getAllFlags() + cmd.DisableAutoGenTag = true if err := doc.GenMarkdownTree(cmd, "./docs/docs/references/configuration/cli"); err != nil { log.Fatal("Fatal error", log.Err(err)) } - if err := generateConfigDocs("./docs/docs/references/configuration/config-file.md"); err != nil { + if err := generateConfigDocs("./docs/docs/references/configuration/config-file.md", allFlagGroups); err != nil { log.Fatal("Fatal error in config file generation", log.Err(err)) } + if err := generateTelemetryFlagDocs("./docs/docs/advanced/telemetry-flags.md", allFlagGroups); err != nil { + log.Fatal("Fatal error in telemetry docs generation", log.Err(err)) + } } -// generateConfigDocs creates markdown file for Trivy config. -func generateConfigDocs(filename string) error { - // remoteFlags should contain Client and Server flags. - // NewClientFlags doesn't initialize `Listen` field - remoteFlags := flag.NewClientFlags() - remoteFlags.Listen = flag.ServerListenFlag.Clone() - - // These flags don't work from config file. - // Clear configName to skip them later. - globalFlags := flag.NewGlobalFlagGroup() - globalFlags.ConfigFile.ConfigName = "" - globalFlags.ShowVersion.ConfigName = "" - globalFlags.GenerateDefaultConfig.ConfigName = "" - - var allFlagGroups = []flag.FlagGroup{ - globalFlags, - flag.NewCacheFlagGroup(), - flag.NewCleanFlagGroup(), - remoteFlags, - flag.NewDBFlagGroup(), - flag.NewImageFlagGroup(), - flag.NewK8sFlagGroup(), - flag.NewLicenseFlagGroup(), - flag.NewMisconfFlagGroup(), - flag.NewModuleFlagGroup(), - flag.NewPackageFlagGroup(), - flag.NewRegistryFlagGroup(), - flag.NewRegoFlagGroup(), - flag.NewReportFlagGroup(), - flag.NewRepoFlagGroup(), - flag.NewScanFlagGroup(), - flag.NewSecretFlagGroup(), - flag.NewVulnerabilityFlagGroup(), +// generateTelemetryFlagDocs updates the telemetry section in the documentation file +// with the flags that are safe to be included in telemetry. +func generateTelemetryFlagDocs(filename string, allFlagGroups []flag.FlagGroup) error { + var telemetryFlags []string + for _, group := range allFlagGroups { + flags := group.Flags() + for _, f := range flags { + if f.IsTelemetrySafe() && f.GetConfigName() != "" { + telemetryFlags = append(telemetryFlags, fmt.Sprintf("--%s", f.GetName())) + } + } } + sort.Strings(telemetryFlags) + flagContent := fmt.Sprintf("```\n%s\n```\n", strings.Join(telemetryFlags, "\n")) + if err := os.WriteFile(filename, []byte(flagContent), 0644); err != nil { + return fmt.Errorf("failed to write to %s: %w", filename, err) + } + return nil +} + +// generateConfigDocs creates markdown file for Trivy config. +func generateConfigDocs(filename string, allFlagGroups []flag.FlagGroup) error { f, err := os.Create(filename) if err != nil { return err @@ -87,6 +80,10 @@ func generateConfigDocs(filename string) error { f.WriteString("# " + title + "\n\n") f.WriteString(description + "\n") + if len(allFlagGroups) == 0 { + return fmt.Errorf("no flag groups found") + } + for _, group := range allFlagGroups { f.WriteString("## " + group.Name() + " options\n") writeFlags(group, f) @@ -161,3 +158,39 @@ func writeFlagValue(val any, ind string, w *os.File) { fmt.Fprintf(w, " %v\n", v) } } + +func getAllFlags() []flag.FlagGroup { + // remoteFlags should contain Client and Server flags. + // NewClientFlags doesn't initialize `Listen` field + remoteFlags := flag.NewClientFlags() + remoteFlags.Listen = flag.ServerListenFlag.Clone() + + // These flags don't work from config file. + // Clear configName to skip them later. + globalFlags := flag.NewGlobalFlagGroup() + globalFlags.ConfigFile.ConfigName = "" + globalFlags.ShowVersion.ConfigName = "" + globalFlags.GenerateDefaultConfig.ConfigName = "" + + return []flag.FlagGroup{ + globalFlags, + flag.NewCacheFlagGroup(), + flag.NewCleanFlagGroup(), + remoteFlags, + flag.NewDBFlagGroup(), + flag.NewImageFlagGroup(), + flag.NewK8sFlagGroup(), + flag.NewLicenseFlagGroup(), + flag.NewMisconfFlagGroup(), + flag.NewModuleFlagGroup(), + flag.NewPackageFlagGroup(), + flag.NewRegistryFlagGroup(), + flag.NewRegoFlagGroup(), + flag.NewReportFlagGroup(), + flag.NewRepoFlagGroup(), + flag.NewScanFlagGroup(), + flag.NewSecretFlagGroup(), + flag.NewVulnerabilityFlagGroup(), + } + +} diff --git a/mkdocs.yml b/mkdocs.yml index 7c82dc6b67..aadefea157 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -262,6 +262,7 @@ markdown_extensions: - pymdownx.highlight - pymdownx.details - pymdownx.magiclink + - pymdownx.snippets - pymdownx.superfences: custom_fences: - name: mermaid diff --git a/pkg/commands/artifact/run.go b/pkg/commands/artifact/run.go index 350fb70e72..60838d7b5d 100644 --- a/pkg/commands/artifact/run.go +++ b/pkg/commands/artifact/run.go @@ -124,13 +124,9 @@ func NewRunner(ctx context.Context, cliOptions flag.Options, opts ...RunnerOptio Insecure: cliOptions.Insecure, Timeout: cliOptions.Timeout, })) - - // If the user has not disabled notices or is running in quiet mode - r.versionChecker = notification.NewVersionChecker( - notification.WithSkipVersionCheck(cliOptions.SkipVersionCheck), - notification.WithQuietMode(cliOptions.Quiet), - notification.WithTelemetryDisabled(cliOptions.DisableTelemetry), - ) + // get the sub command that is being used or fallback to "trivy" + commandName := lo.Ternary(len(os.Args) > 1, os.Args[1], "trivy") + r.versionChecker = notification.NewVersionChecker(commandName, &cliOptions) // Update the vulnerability database if needed. if err := r.initDB(ctx, cliOptions); err != nil { @@ -157,7 +153,7 @@ func NewRunner(ctx context.Context, cliOptions flag.Options, opts ...RunnerOptio // only do this if the user has not disabled notices or is running // in quiet mode if r.versionChecker != nil { - r.versionChecker.RunUpdateCheck(ctx, os.Args[1:]) + r.versionChecker.RunUpdateCheck(ctx) } return r, nil diff --git a/pkg/flag/aws_flags.go b/pkg/flag/aws_flags.go index 77c04d1fc7..f6b7c11393 100644 --- a/pkg/flag/aws_flags.go +++ b/pkg/flag/aws_flags.go @@ -12,14 +12,16 @@ var ( Usage: "AWS Endpoint override", } awsServiceFlag = Flag[[]string]{ - Name: "service", - ConfigName: "cloud.aws.service", - Usage: "Only scan AWS Service(s) specified with this flag. Can specify multiple services using --service A --service B etc.", + Name: "service", + ConfigName: "cloud.aws.service", + Usage: "Only scan AWS Service(s) specified with this flag. Can specify multiple services using --service A --service B etc.", + TelemetrySafe: true, } awsSkipServicesFlag = Flag[[]string]{ - Name: "skip-service", - ConfigName: "cloud.aws.skip-service", - Usage: "Skip selected AWS Service(s) specified with this flag. Can specify multiple services using --skip-service A --skip-service B etc.", + Name: "skip-service", + ConfigName: "cloud.aws.skip-service", + Usage: "Skip selected AWS Service(s) specified with this flag. Can specify multiple services using --skip-service A --skip-service B etc.", + TelemetrySafe: true, } awsAccountFlag = Flag[string]{ Name: "account", diff --git a/pkg/flag/global_flags.go b/pkg/flag/global_flags.go index eb846406cd..9e6ed1a265 100644 --- a/pkg/flag/global_flags.go +++ b/pkg/flag/global_flags.go @@ -27,31 +27,35 @@ var ( Persistent: true, } QuietFlag = Flag[bool]{ - Name: "quiet", - ConfigName: "quiet", - Shorthand: "q", - Usage: "suppress progress bar and log output", - Persistent: true, + Name: "quiet", + ConfigName: "quiet", + Shorthand: "q", + Usage: "suppress progress bar and log output", + Persistent: true, + TelemetrySafe: true, } DebugFlag = Flag[bool]{ - Name: "debug", - ConfigName: "debug", - Shorthand: "d", - Usage: "debug mode", - Persistent: true, + Name: "debug", + ConfigName: "debug", + Shorthand: "d", + Usage: "debug mode", + Persistent: true, + TelemetrySafe: true, } InsecureFlag = Flag[bool]{ - Name: "insecure", - ConfigName: "insecure", - Usage: "allow insecure server connections", - Persistent: true, + Name: "insecure", + ConfigName: "insecure", + Usage: "allow insecure server connections", + Persistent: true, + TelemetrySafe: true, } TimeoutFlag = Flag[time.Duration]{ - Name: "timeout", - ConfigName: "timeout", - Default: time.Second * 300, // 5 mins - Usage: "timeout", - Persistent: true, + Name: "timeout", + ConfigName: "timeout", + Default: time.Second * 300, // 5 mins + Usage: "timeout", + Persistent: true, + TelemetrySafe: true, } CacheDirFlag = Flag[string]{ Name: "cache-dir", diff --git a/pkg/flag/misconf_flags.go b/pkg/flag/misconf_flags.go index 09a9b1566c..26fd842f68 100644 --- a/pkg/flag/misconf_flags.go +++ b/pkg/flag/misconf_flags.go @@ -102,7 +102,8 @@ var ( Default: xstrings.ToStringSlice( lo.Without(analyzer.TypeConfigFiles, analyzer.TypeYAML, analyzer.TypeJSON), ), - Usage: "comma-separated list of misconfig scanners to use for misconfiguration scanning", + Usage: "comma-separated list of misconfig scanners to use for misconfiguration scanning", + TelemetrySafe: true, } ConfigFileSchemasFlag = Flag[[]string]{ Name: "config-file-schemas", diff --git a/pkg/flag/options.go b/pkg/flag/options.go index 26eb5c7022..f9a644e24f 100644 --- a/pkg/flag/options.go +++ b/pkg/flag/options.go @@ -76,6 +76,9 @@ type Flag[T FlagType] struct { // Aliases represents aliases Aliases []Alias + // TelemetrySafe indicates if the flag value is safe to be included in telemetry. + TelemetrySafe bool + // value is the value passed through CLI flag, env, or config file. // It is populated after flag.Parse() is called. value T @@ -218,6 +221,17 @@ func (f *Flag[T]) GetAliases() []Alias { return f.Aliases } +func (f *Flag[T]) IsTelemetrySafe() bool { + return f.TelemetrySafe +} + +func (f *Flag[T]) IsSet() bool { + if f == nil { + return false + } + return f.isSet() +} + func (f *Flag[T]) Hidden() bool { return f.Deprecated != "" || f.Removed != "" || f.Internal } @@ -349,6 +363,8 @@ type Flagger interface { GetDefaultValue() any GetAliases() []Alias Hidden() bool + IsTelemetrySafe() bool + IsSet() bool Parse() error Add(cmd *cobra.Command) @@ -391,6 +407,9 @@ type Options struct { // args is the arguments passed to the command. args []string + + // usedFlags allows us to get the underlying flags for the options + usedFlags []Flagger } // Align takes consistency of options @@ -555,6 +574,11 @@ func (o *Options) OutputWriter(ctx context.Context) (io.Writer, func() error, er return f, f.Close, nil } +// GetUsedFlags returns the explicitly set flags for the options. +func (o *Options) GetUsedFlags() []Flagger { + return o.usedFlags +} + func (o *Options) outputPluginWriter(ctx context.Context) (io.Writer, func() error, error) { pluginName := strings.TrimPrefix(o.Output, "plugin=") @@ -651,6 +675,8 @@ func (f *Flags) ToOptions(args []string) (Options, error) { return Options{}, xerrors.Errorf("unable to parse flags: %w", err) } + opts.usedFlags = append(opts.usedFlags, usedFlags(group)...) + if err := group.ToOptions(&opts); err != nil { return Options{}, xerrors.Errorf("unable to convert flags to options: %w", err) } @@ -751,3 +777,21 @@ func findFlagGroup[T FlagGroup](f *Flags) (T, bool) { var zero T return zero, false } + +// usedFlags returns a slice of flags that are set in the given FlagGroup. +func usedFlags(fg FlagGroup) []Flagger { + if fg == nil || fg.Flags() == nil { + return nil + } + + var flags []Flagger + for _, flag := range fg.Flags() { + if flag == nil { + continue + } + if flag.IsSet() { + flags = append(flags, flag) + } + } + return flags +} diff --git a/pkg/flag/package_flags.go b/pkg/flag/package_flags.go index 8de4d265f0..256c35a4b0 100644 --- a/pkg/flag/package_flags.go +++ b/pkg/flag/package_flags.go @@ -8,9 +8,10 @@ import ( var ( IncludeDevDepsFlag = Flag[bool]{ - Name: "include-dev-deps", - ConfigName: "pkg.include-dev-deps", - Usage: "include development dependencies in the report (supported: npm, yarn, gradle)", + Name: "include-dev-deps", + ConfigName: "pkg.include-dev-deps", + Usage: "include development dependencies in the report (supported: npm, yarn, gradle)", + TelemetrySafe: true, } PkgTypesFlag = Flag[[]string]{ Name: "pkg-types", @@ -25,13 +26,15 @@ var ( Deprecated: true, // --vuln-type was renamed to --pkg-types }, }, + TelemetrySafe: true, } PkgRelationshipsFlag = Flag[[]string]{ - Name: "pkg-relationships", - ConfigName: "pkg.relationships", - Default: xstrings.ToStringSlice(ftypes.Relationships), - Values: xstrings.ToStringSlice(ftypes.Relationships), - Usage: "list of package relationships", + Name: "pkg-relationships", + ConfigName: "pkg.relationships", + Default: xstrings.ToStringSlice(ftypes.Relationships), + Values: xstrings.ToStringSlice(ftypes.Relationships), + Usage: "list of package relationships", + TelemetrySafe: true, } ) diff --git a/pkg/flag/report_flags.go b/pkg/flag/report_flags.go index 13d9a51b75..dc03c0b16d 100644 --- a/pkg/flag/report_flags.go +++ b/pkg/flag/report_flags.go @@ -26,12 +26,13 @@ import ( // severity: HIGH,CRITICAL var ( FormatFlag = Flag[string]{ - Name: "format", - ConfigName: "format", - Shorthand: "f", - Default: string(types.FormatTable), - Values: xstrings.ToStringSlice(types.SupportedFormats), - Usage: "format", + Name: "format", + ConfigName: "format", + Shorthand: "f", + Default: string(types.FormatTable), + Values: xstrings.ToStringSlice(types.SupportedFormats), + Usage: "format", + TelemetrySafe: true, } ReportFormatFlag = Flag[string]{ Name: "report", @@ -41,7 +42,8 @@ var ( "all", "summary", }, - Usage: "specify a report format for the output", + Usage: "specify a report format for the output", + TelemetrySafe: true, } TemplateFlag = Flag[string]{ Name: "template", @@ -55,9 +57,10 @@ var ( Usage: "[EXPERIMENTAL] show dependency origin tree of vulnerable packages", } ListAllPkgsFlag = Flag[bool]{ - Name: "list-all-pkgs", - ConfigName: "list-all-pkgs", - Usage: "output all packages in the JSON report regardless of vulnerability", + Name: "list-all-pkgs", + ConfigName: "list-all-pkgs", + Usage: "output all packages in the JSON report regardless of vulnerability", + TelemetrySafe: true, } IgnoreFileFlag = Flag[string]{ Name: "ignorefile", @@ -92,12 +95,13 @@ var ( Usage: "[EXPERIMENTAL] output plugin arguments", } SeverityFlag = Flag[[]string]{ - Name: "severity", - ConfigName: "severity", - Shorthand: "s", - Default: dbTypes.SeverityNames, - Values: dbTypes.SeverityNames, - Usage: "severities of security issues to be displayed", + Name: "severity", + ConfigName: "severity", + Shorthand: "s", + Default: dbTypes.SeverityNames, + Values: dbTypes.SeverityNames, + Usage: "severities of security issues to be displayed", + TelemetrySafe: true, } ComplianceFlag = Flag[string]{ Name: "compliance", @@ -105,9 +109,10 @@ var ( Usage: "compliance report to generate", } ShowSuppressedFlag = Flag[bool]{ - Name: "show-suppressed", - ConfigName: "scan.show-suppressed", - Usage: "[EXPERIMENTAL] show suppressed vulnerabilities", + Name: "show-suppressed", + ConfigName: "scan.show-suppressed", + Usage: "[EXPERIMENTAL] show suppressed vulnerabilities", + TelemetrySafe: true, } TableModeFlag = Flag[[]string]{ Name: "table-mode", diff --git a/pkg/flag/scan_flags.go b/pkg/flag/scan_flags.go index 6e83608536..d26325c11d 100644 --- a/pkg/flag/scan_flags.go +++ b/pkg/flag/scan_flags.go @@ -65,7 +65,8 @@ var ( Deprecated: true, // --security-checks was renamed to --scanners }, }, - Usage: "comma-separated list of what security issues to detect", + Usage: "comma-separated list of what security issues to detect", + TelemetrySafe: true, } FilePatternsFlag = Flag[[]string]{ Name: "file-patterns", @@ -112,6 +113,7 @@ var ( - "precise": Prioritizes precise by minimizing false positives. - "comprehensive": Aims to detect more security findings at the cost of potential false positives. `, + TelemetrySafe: true, } DistroFlag = Flag[string]{ Name: "distro", diff --git a/pkg/flag/vulnerability_flags.go b/pkg/flag/vulnerability_flags.go index 50a5984e92..588b2bc3e3 100644 --- a/pkg/flag/vulnerability_flags.go +++ b/pkg/flag/vulnerability_flags.go @@ -17,10 +17,11 @@ var ( Usage: "display only fixed vulnerabilities", } IgnoreStatusFlag = Flag[[]string]{ - Name: "ignore-status", - ConfigName: "vulnerability.ignore-status", - Values: dbTypes.Statuses, - Usage: "comma-separated list of vulnerability status to ignore", + Name: "ignore-status", + ConfigName: "vulnerability.ignore-status", + Values: dbTypes.Statuses, + Usage: "comma-separated list of vulnerability status to ignore", + TelemetrySafe: true, } VEXFlag = Flag[[]string]{ Name: "vex", @@ -38,8 +39,9 @@ var ( Default: []string{ "auto", }, - Values: append(xstrings.ToStringSlice(vulnerability.AllSourceIDs), "auto"), - Usage: "order of data sources for selecting vulnerability severity level", + Values: append(xstrings.ToStringSlice(vulnerability.AllSourceIDs), "auto"), + Usage: "order of data sources for selecting vulnerability severity level", + TelemetrySafe: true, } ) diff --git a/pkg/notification/notice.go b/pkg/notification/notice.go index 6d1dcf01e9..075bf20c6a 100644 --- a/pkg/notification/notice.go +++ b/pkg/notification/notice.go @@ -8,20 +8,23 @@ import ( "io" "net/http" "runtime" + "strconv" "strings" "time" + "github.com/samber/lo" + "github.com/aquasecurity/go-version/pkg/semver" + "github.com/aquasecurity/trivy/pkg/flag" "github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/version/app" xhttp "github.com/aquasecurity/trivy/pkg/x/http" ) type VersionChecker struct { - updatesApi string - skipUpdateCheck bool - quiet bool - telemetryDisabled bool + updatesApi string + commandName string + cliOptions *flag.Options done bool responseReceived bool @@ -30,17 +33,15 @@ type VersionChecker struct { } // NewVersionChecker creates a new VersionChecker with the default -// updates API URL. The URL can be overridden by passing an Option -// to the NewVersionChecker function. -func NewVersionChecker(opts ...Option) *VersionChecker { +// updates API URL. +func NewVersionChecker(commandName string, cliOptions *flag.Options) *VersionChecker { v := &VersionChecker{ updatesApi: "https://check.trivy.dev/updates", currentVersion: app.Version(), + commandName: commandName, + cliOptions: cliOptions, } - for _, opt := range opts { - opt(v) - } return v } @@ -50,17 +51,17 @@ func NewVersionChecker(opts ...Option) *VersionChecker { // 1. if skipUpdateCheck is true AND telemetryDisabled are both true, skip the request // 2. if skipUpdateCheck is true AND telemetryDisabled is false, run check with metric details but suppress output // 3. if skipUpdateCheck is false AND telemetryDisabled is true, run update check but don't send any metric identifiers -func (v *VersionChecker) RunUpdateCheck(ctx context.Context, args []string) { +func (v *VersionChecker) RunUpdateCheck(ctx context.Context) { logger := log.WithPrefix("notification") - if v.skipUpdateCheck && v.telemetryDisabled { + if v.cliOptions.SkipVersionCheck && v.cliOptions.DisableTelemetry { logger.Debug("Skipping update check and metric ping") return } go func() { logger.Debug("Running version check") - args = getFlags(args) + commandParts := v.getFlags() client := xhttp.ClientWithContext(ctx, xhttp.WithTimeout(3*time.Second)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.updatesApi, http.NoBody) @@ -70,9 +71,10 @@ func (v *VersionChecker) RunUpdateCheck(ctx context.Context, args []string) { } // if the user hasn't disabled metrics, send the anonymous information as headers - if !v.telemetryDisabled { + if !v.cliOptions.DisableTelemetry { req.Header.Set("Trivy-Identifier", uniqueIdentifier()) - req.Header.Set("Trivy-Command", strings.Join(args, " ")) + req.Header.Set("Trivy-Command", v.commandName) + req.Header.Set("Trivy-Flags", commandParts) req.Header.Set("Trivy-OS", runtime.GOOS) req.Header.Set("Trivy-Arch", runtime.GOARCH) } @@ -91,7 +93,7 @@ func (v *VersionChecker) RunUpdateCheck(ctx context.Context, args []string) { } // enable priting if update allowed and quiet mode is not set - if !v.skipUpdateCheck && !v.quiet { + if !v.cliOptions.SkipVersionCheck && !v.cliOptions.Quiet { v.responseReceived = true } logger.Debug("Version check completed", log.String("latest_version", v.latestVersion.Trivy.LatestVersion)) @@ -175,17 +177,6 @@ func (v *VersionChecker) Warnings() []string { return nil } -// getFlags returns the just the flag portion without the values -func getFlags(args []string) []string { - var flags []string - for _, arg := range args { - if strings.HasPrefix(arg, "-") { - flags = append(flags, strings.Split(arg, "=")[0]) - } - } - return flags -} - func (fd *flexibleTime) UnmarshalJSON(b []byte) error { s := strings.Trim(string(b), `"`) if s == "" { @@ -211,3 +202,39 @@ func (fd *flexibleTime) UnmarshalJSON(b []byte) error { return fmt.Errorf("unable to parse date: %s", s) } + +func (v *VersionChecker) getFlags() string { + var flags []string + for _, f := range v.cliOptions.GetUsedFlags() { + name := f.GetName() + if name == "" { + continue // Skip flags without a name + } + value := lo.Ternary(!f.IsTelemetrySafe(), "***", getFlagValue(f)) + + flags = append(flags, fmt.Sprintf("--%s=%s", name, value)) + } + return strings.Join(flags, " ") +} + +func getFlagValue(f flag.Flagger) string { + type flagger[T flag.FlagType] interface { + Value() T + } + switch ff := f.(type) { + case flagger[string]: + return ff.Value() + case flagger[int]: + return strconv.Itoa(ff.Value()) + case flagger[float64]: + return fmt.Sprintf("%f", ff.Value()) + case flagger[bool]: + return strconv.FormatBool(ff.Value()) + case flagger[time.Duration]: + return ff.Value().String() + case flagger[[]string]: + return strings.Join(ff.Value(), ",") + default: + return "***" // Default case for unsupported types + } +} diff --git a/pkg/notification/notice_test.go b/pkg/notification/notice_test.go index 1e25d1578a..1711348a47 100644 --- a/pkg/notification/notice_test.go +++ b/pkg/notification/notice_test.go @@ -9,14 +9,21 @@ import ( "testing" "time" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/flag" ) func TestPrintNotices(t *testing.T) { tests := []struct { name string - options []Option + skipVersionCheck bool + quiet bool + disableTelemetry bool + + currentVersion string latestVersion string announcements []announcement responseExpected bool @@ -24,42 +31,38 @@ func TestPrintNotices(t *testing.T) { }{ { name: "New version with no announcements", - options: []Option{WithCurrentVersion("0.58.0")}, + currentVersion: "0.58.0", latestVersion: "0.60.0", responseExpected: true, expectedOutput: "\n📣 \x1b[34mNotices:\x1b[0m\n - Version 0.60.0 of Trivy is now available, current version is 0.58.0\n\nTo suppress version checks, run Trivy scans with the --skip-version-check flag\n\n", }, { name: "New version available but includes a prefixed version number", - options: []Option{WithCurrentVersion("0.58.0")}, + currentVersion: "0.58.0", latestVersion: "v0.60.0", responseExpected: true, expectedOutput: "\n📣 \x1b[34mNotices:\x1b[0m\n - Version 0.60.0 of Trivy is now available, current version is 0.58.0\n\nTo suppress version checks, run Trivy scans with the --skip-version-check flag\n\n", }, { - name: "new version available but --quiet mode enabled", - options: []Option{ - WithCurrentVersion("0.58.0"), - WithQuietMode(true), - }, + name: "new version available but --quiet mode enabled", + quiet: true, + currentVersion: "0.58.0", latestVersion: "0.60.0", responseExpected: false, expectedOutput: "", }, { - name: "new version available but --skip-update-check mode enabled", - options: []Option{ - WithCurrentVersion("0.58.0"), - WithSkipVersionCheck(true), - }, + name: "new version available but --skip-version-check mode enabled", + skipVersionCheck: true, + currentVersion: "0.58.0", latestVersion: "0.60.0", responseExpected: false, expectedOutput: "", }, { - name: "New version with announcements", - options: []Option{WithCurrentVersion("0.58.0")}, - latestVersion: "0.60.0", + name: "New version with announcements", + currentVersion: "0.58.0", + latestVersion: "0.60.0", announcements: []announcement{ { FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), @@ -71,9 +74,9 @@ func TestPrintNotices(t *testing.T) { expectedOutput: "\n📣 \x1b[34mNotices:\x1b[0m\n - There are some amazing things happening right now!\n - Version 0.60.0 of Trivy is now available, current version is 0.58.0\n\nTo suppress version checks, run Trivy scans with the --skip-version-check flag\n\n", }, { - name: "No new version with announcements", - options: []Option{WithCurrentVersion("0.60.0")}, - latestVersion: "0.60.0", + name: "No new version with announcements", + currentVersion: "0.60.0", + latestVersion: "0.60.0", announcements: []announcement{ { FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), @@ -85,9 +88,9 @@ func TestPrintNotices(t *testing.T) { expectedOutput: "\n📣 \x1b[34mNotices:\x1b[0m\n - There are some amazing things happening right now!\n\nTo suppress version checks, run Trivy scans with the --skip-version-check flag\n\n", }, { - name: "No new version with announcements and zero time", - options: []Option{WithCurrentVersion("0.60.0")}, - latestVersion: "0.60.0", + name: "No new version with announcements and zero time", + currentVersion: "0.60.0", + latestVersion: "0.60.0", announcements: []announcement{ { FromDate: time.Time{}, @@ -99,9 +102,9 @@ func TestPrintNotices(t *testing.T) { expectedOutput: "\n📣 \x1b[34mNotices:\x1b[0m\n - There are some amazing things happening right now!\n\nTo suppress version checks, run Trivy scans with the --skip-version-check flag\n\n", }, { - name: "No new version with announcement that fails announcement version constraints", - options: []Option{WithCurrentVersion("0.60.0")}, - latestVersion: "0.60.0", + name: "No new version with announcement that fails announcement version constraints", + currentVersion: "0.60.0", + latestVersion: "0.60.0", announcements: []announcement{ { FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), @@ -114,9 +117,9 @@ func TestPrintNotices(t *testing.T) { expectedOutput: "", }, { - name: "No new version with announcement where current version is greater than to_version", - options: []Option{WithCurrentVersion("0.60.0")}, - latestVersion: "0.60.0", + name: "No new version with announcement where current version is greater than to_version", + currentVersion: "0.60.0", + latestVersion: "0.60.0", announcements: []announcement{ { FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), @@ -129,9 +132,9 @@ func TestPrintNotices(t *testing.T) { expectedOutput: "", }, { - name: "No new version with announcement that satisfies version constraint but outside date range", - options: []Option{WithCurrentVersion("0.60.0")}, - latestVersion: "0.60.0", + name: "No new version with announcement that satisfies version constraint but outside date range", + currentVersion: "0.60.0", + latestVersion: "0.60.0", announcements: []announcement{ { FromDate: time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC), @@ -144,9 +147,9 @@ func TestPrintNotices(t *testing.T) { expectedOutput: "", }, { - name: "No new version with multiple announcements, one of which is valid", - options: []Option{WithCurrentVersion("0.60.0")}, - latestVersion: "0.60.0", + name: "No new version with multiple announcements, one of which is valid", + currentVersion: "0.60.0", + latestVersion: "0.60.0", announcements: []announcement{ { FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), @@ -165,7 +168,8 @@ func TestPrintNotices(t *testing.T) { }, { name: "No new version with no announcements and quiet mode", - options: []Option{WithCurrentVersion("0.60.0"), WithQuietMode(true)}, + quiet: true, + currentVersion: "0.60.0", latestVersion: "0.60.0", announcements: []announcement{}, responseExpected: false, @@ -173,7 +177,7 @@ func TestPrintNotices(t *testing.T) { }, { name: "No new version with no announcements", - options: []Option{WithCurrentVersion("0.60.0")}, + currentVersion: "0.60.0", latestVersion: "0.60.0", announcements: []announcement{}, responseExpected: true, @@ -185,11 +189,22 @@ func TestPrintNotices(t *testing.T) { t.Run(tt.name, func(t *testing.T) { updates := newUpdatesServer(t, tt.latestVersion, tt.announcements) server := httptest.NewServer(http.HandlerFunc(updates.handler)) - defer server.Close() - tt.options = append(tt.options, WithUpdatesApi(server.URL)) - v := NewVersionChecker(tt.options...) - v.RunUpdateCheck(t.Context(), nil) + cliOpts := &flag.Options{ + GlobalOptions: flag.GlobalOptions{ + Quiet: tt.quiet, + }, + ScanOptions: flag.ScanOptions{ + SkipVersionCheck: tt.skipVersionCheck, + DisableTelemetry: tt.disableTelemetry, + }, + } + + v := NewVersionChecker("testCommand", cliOpts) + v.updatesApi = server.URL + v.currentVersion = tt.currentVersion + + v.RunUpdateCheck(t.Context()) require.Eventually(t, func() bool { return v.done }, time.Second*5, 500) require.Eventually(t, func() bool { return v.responseReceived == tt.responseExpected }, time.Second*5, 500) @@ -207,32 +222,29 @@ func TestPrintNotices(t *testing.T) { func TestCheckForNotices(t *testing.T) { tests := []struct { name string - options []Option + skipVersionCheck bool + disableTelemetry bool + quiet bool + currentVersion string expectedVersion string expectedAnnouncements []announcement expectNoMetrics bool }{ { - name: "new version with no announcements", - options: []Option{ - WithCurrentVersion("0.58.0"), - }, + name: "new version with no announcements", + currentVersion: "0.58.0", expectedVersion: "0.60.0", }, { - name: "new version with disabled metrics", - options: []Option{ - WithCurrentVersion("0.58.0"), - WithTelemetryDisabled(true), - }, - expectedVersion: "0.60.0", - expectNoMetrics: true, + name: "new version with disabled metrics", + disableTelemetry: true, + currentVersion: "0.58.0", + expectedVersion: "0.60.0", + expectNoMetrics: true, }, { - name: "new version and a new announcement", - options: []Option{ - WithCurrentVersion("0.58.0"), - }, + name: "new version and a new announcement", + currentVersion: "0.58.0", expectedVersion: "0.60.0", expectedAnnouncements: []announcement{ { @@ -250,10 +262,20 @@ func TestCheckForNotices(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(updates.handler)) defer server.Close() - tt.options = append(tt.options, WithUpdatesApi(server.URL)) - v := NewVersionChecker(tt.options...) + cliOpts := &flag.Options{ + GlobalOptions: flag.GlobalOptions{ + Quiet: tt.quiet, + }, + ScanOptions: flag.ScanOptions{ + SkipVersionCheck: tt.skipVersionCheck, + DisableTelemetry: tt.disableTelemetry, + }, + } - v.RunUpdateCheck(t.Context(), nil) + v := NewVersionChecker("testCommand", cliOpts) + v.updatesApi = server.URL + + v.RunUpdateCheck(t.Context()) require.Eventually(t, func() bool { return v.done }, time.Second*5, 500) require.Eventually(t, func() bool { return v.responseReceived }, time.Second*5, 500) latestVersion, err := v.LatestVersion() @@ -262,11 +284,9 @@ func TestCheckForNotices(t *testing.T) { assert.ElementsMatch(t, tt.expectedAnnouncements, v.Announcements()) if tt.expectNoMetrics { - assert.True(t, v.telemetryDisabled) require.NotNil(t, updates.lastRequest) assert.Empty(t, updates.lastRequest.Header.Get("Trivy-Identifier")) } else { - assert.False(t, v.telemetryDisabled) require.NotNil(t, updates.lastRequest) assert.NotEmpty(t, updates.lastRequest.Header.Get("Trivy-Identifier")) } @@ -344,3 +364,116 @@ func TestFlexibleDate(t *testing.T) { }) } } + +func TestCheckCommandHeaders(t *testing.T) { + tests := []struct { + name string + command string + commandArgs []string + env map[string]string + ignoreParseError bool + expectedCommandHeader string + expectedCommandArgsHeader string + }{ + { + name: "image command with no flags", + command: "image", + commandArgs: []string{"nginx"}, + expectedCommandHeader: "image", + }, + { + name: "image command with flags", + command: "image", + commandArgs: []string{"--severity", "CRITICAL", "--scanners", "vuln,misconfig", "--pkg-types", "library", "nginx", "--include-dev-deps"}, + expectedCommandHeader: "image", + expectedCommandArgsHeader: "--include-dev-deps=true --pkg-types=library --severity=CRITICAL --scanners=vuln,misconfig", + }, + { + name: "image command with multiple flags", + command: "image", + commandArgs: []string{"--severity", "MEDIUM", "-s", "CRITICAL", "--scanners", "misconfig", "nginx"}, + expectedCommandHeader: "image", + expectedCommandArgsHeader: "--severity=MEDIUM,CRITICAL --scanners=misconfig", + }, + { + name: "filesystem command with flags", + command: "fs", + commandArgs: []string{"--severity=HIGH", "--vex", "repo", "--vuln-severity-source", "nvd,debian", "../trivy-ci-test"}, + expectedCommandHeader: "fs", + expectedCommandArgsHeader: "--severity=HIGH --vex=*** --vuln-severity-source=nvd,debian", + }, + { + name: "filesystem command with flags including an invalid flag", + command: "fs", + commandArgs: []string{"--severity=HIGH", "--vex", "repo", "--vuln-severity-source", "nvd,debian", "--invalid-flag", "../trivy-ci-test"}, + ignoreParseError: true, + expectedCommandHeader: "fs", + expectedCommandArgsHeader: "--severity=HIGH --vex=*** --vuln-severity-source=nvd,debian", + }, + { + name: "filesystem with environment variables", + command: "fs", + commandArgs: []string{"--severity", "HIGH", "--vex", "repo", "/home/user/code"}, + env: map[string]string{ + "TRIVY_SCANNERS": "secret,misconfig", + }, + expectedCommandHeader: "fs", + expectedCommandArgsHeader: "--severity=HIGH --scanners=secret,misconfig --vex=***", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updates := newUpdatesServer(t, "0.60.0", nil) + server := httptest.NewServer(http.HandlerFunc(updates.handler)) + defer server.Close() + + for key, value := range tt.env { + t.Setenv(key, value) + } + + // clean up the env + defer func() { + server.Close() + }() + + opts := getOptionsForArgs(t, tt.commandArgs, tt.ignoreParseError) + + v := NewVersionChecker(tt.command, opts) + v.updatesApi = server.URL + v.RunUpdateCheck(t.Context()) + + require.Eventually(t, func() bool { return v.done }, time.Second*5, 500) + require.NotNil(t, updates.lastRequest) + assert.Equal(t, tt.expectedCommandHeader, updates.lastRequest.Header.Get("Trivy-Command")) + assert.Equal(t, tt.expectedCommandArgsHeader, updates.lastRequest.Header.Get("Trivy-Flags")) + }) + } +} + +// getOptionsForArgs uses a basic command to parse the flags so we can generate +// an options object from it +func getOptionsForArgs(t *testing.T, commandArgs []string, ignoreParseError bool) *flag.Options { + flags := flag.Flags{ + flag.NewGlobalFlagGroup(), + flag.NewImageFlagGroup(), + flag.NewMisconfFlagGroup(), + flag.NewPackageFlagGroup(), + flag.NewReportFlagGroup(), + flag.NewScanFlagGroup(), + flag.NewVulnerabilityFlagGroup(), + } + + // simple command to facilitate flag parsing + cmd := &cobra.Command{} + flags.AddFlags(cmd) + err := cmd.ParseFlags(commandArgs) + if !ignoreParseError { + require.NoError(t, err) + } + + require.NoError(t, flags.Bind(cmd)) + opts, err := flags.ToOptions(commandArgs) + require.NoError(t, err) + return &opts +} diff --git a/pkg/notification/option.go b/pkg/notification/option.go deleted file mode 100644 index 8dff31763b..0000000000 --- a/pkg/notification/option.go +++ /dev/null @@ -1,37 +0,0 @@ -package notification - -type Option func(*VersionChecker) - -// WithUpdatesApi sets the updates API URL -func WithUpdatesApi(updatesApi string) Option { - return func(v *VersionChecker) { - v.updatesApi = updatesApi - } -} - -// WithCurrentVersion sets the current version -func WithCurrentVersion(version string) Option { - return func(v *VersionChecker) { - v.currentVersion = version - } -} - -func WithSkipVersionCheck(skipVersionCheck bool) Option { - return func(v *VersionChecker) { - v.skipUpdateCheck = skipVersionCheck - } -} - -// WithQuietMode sets the quiet mode when the user is using the --quiet flag -func WithQuietMode(quiet bool) Option { - return func(v *VersionChecker) { - v.quiet = quiet - } -} - -// WithTelemetryDisabled sets the telemetry disabled flag -func WithTelemetryDisabled(telemetryDisabled bool) Option { - return func(v *VersionChecker) { - v.telemetryDisabled = telemetryDisabled - } -}