diff --git a/cmd/grype/cli/options/datasources.go b/cmd/grype/cli/options/datasources.go index c0f5c00d6d8..f58e5b98fce 100644 --- a/cmd/grype/cli/options/datasources.go +++ b/cmd/grype/cli/options/datasources.go @@ -24,6 +24,7 @@ type maven struct { SearchUpstreamBySha1 bool `yaml:"search-upstream" json:"searchUpstreamBySha1" mapstructure:"search-maven-upstream"` BaseURL string `yaml:"base-url" json:"baseUrl" mapstructure:"base-url"` RateLimit time.Duration `yaml:"rate-limit" json:"rateLimit" mapstructure:"rate-limit"` + Timeout time.Duration `yaml:"timeout" json:"timeout" mapstructure:"timeout"` } func defaultExternalSources() externalSources { @@ -32,6 +33,7 @@ func defaultExternalSources() externalSources { SearchUpstreamBySha1: true, BaseURL: defaultMavenBaseURL, RateLimit: 300 * time.Millisecond, + Timeout: 10 * time.Second, }, } } @@ -46,6 +48,7 @@ func (cfg externalSources) ToJavaMatcherConfig() java.ExternalSearchConfig { SearchMavenUpstream: smu, MavenBaseURL: cfg.Maven.BaseURL, MavenRateLimit: cfg.Maven.RateLimit, + MavenTimeout: cfg.Maven.Timeout, } } @@ -53,4 +56,5 @@ func (cfg *externalSources) DescribeFields(descriptions clio.FieldDescriptionSet descriptions.Add(&cfg.Enable, `enable Grype searching network source for additional information`) descriptions.Add(&cfg.Maven.SearchUpstreamBySha1, `search for Maven artifacts by SHA1`) descriptions.Add(&cfg.Maven.BaseURL, `base URL of the Maven repository to search`) + descriptions.Add(&cfg.Maven.Timeout, `per-request timeout for Maven SHA1 lookups; 0 disables the timeout`) } diff --git a/grype/matcher/java/matcher.go b/grype/matcher/java/matcher.go index fd921fa018d..639e621eae9 100644 --- a/grype/matcher/java/matcher.go +++ b/grype/matcher/java/matcher.go @@ -28,6 +28,7 @@ type ExternalSearchConfig struct { SearchMavenUpstream bool MavenBaseURL string MavenRateLimit time.Duration + MavenTimeout time.Duration } type MatcherConfig struct { @@ -36,9 +37,14 @@ type MatcherConfig struct { } func NewJavaMatcher(cfg MatcherConfig) *Matcher { + client := http.DefaultClient + if cfg.MavenTimeout > 0 { + // dedicated client so a configured per-request timeout does not bleed onto unrelated callers + client = &http.Client{Timeout: cfg.MavenTimeout} + } return &Matcher{ cfg: cfg, - MavenSearcher: newMavenSearch(http.DefaultClient, cfg.MavenBaseURL, cfg.MavenRateLimit), + MavenSearcher: newMavenSearch(client, cfg.MavenBaseURL, cfg.MavenRateLimit), } } diff --git a/grype/matcher/java/maven_test.go b/grype/matcher/java/maven_test.go index 3427cd8d36d..8014fba1cd7 100644 --- a/grype/matcher/java/maven_test.go +++ b/grype/matcher/java/maven_test.go @@ -98,3 +98,58 @@ func withinDelta(got, want, delta time.Duration) bool { } return diff <= delta } + +func TestNewJavaMatcherTimeout(t *testing.T) { + // stand up a server that hangs forever so any successful response would only happen + // because the configured timeout failed to abort the request. The hang channel must be + // closed before ts.Close() so any in-flight handlers unblock cleanly during shutdown. + hang := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-hang + })) + t.Cleanup(func() { + close(hang) + ts.Close() + }) + + t.Run("timeout aborts hanging upstream", func(t *testing.T) { + m := NewJavaMatcher(MatcherConfig{ + ExternalSearchConfig: ExternalSearchConfig{ + MavenBaseURL: ts.URL, + MavenRateLimit: time.Nanosecond, + MavenTimeout: 100 * time.Millisecond, + }, + }) + + start := time.Now() + _, err := m.GetMavenPackageBySha(context.Background(), "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected timeout error, got nil") + } + // give the runtime a generous ceiling so this test stays stable on slow CI + if elapsed > 2*time.Second { + t.Errorf("timeout did not fire promptly: elapsed = %v", elapsed) + } + }) + + t.Run("zero timeout disables the per-request limit", func(t *testing.T) { + m := NewJavaMatcher(MatcherConfig{ + ExternalSearchConfig: ExternalSearchConfig{ + MavenBaseURL: ts.URL, + // MavenTimeout intentionally unset (zero value) + }, + }) + + // the underlying Matcher's MavenSearcher should be using http.DefaultClient, + // which has no Timeout set + ms, ok := m.MavenSearcher.(*mavenSearch) + if !ok { + t.Fatalf("MavenSearcher is not *mavenSearch, got %T", m.MavenSearcher) + } + if ms.client.Timeout != 0 { + t.Errorf("expected zero timeout on default client, got %v", ms.client.Timeout) + } + }) +}