From 9bb03994c56972f1515bd4bf0b9c3a1481a603dc Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 4 Oct 2026 18:21:18 +0100 Subject: [PATCH 1/3] Add cooldown package pattern overrides --- config.example.yaml | 4 + docs/configuration.md | 9 +- internal/config/config.go | 4 + internal/config/config_test.go | 5 + internal/cooldownpolicy/policy.go | 101 +++++++++++++ internal/cooldownpolicy/policy_test.go | 167 +++++++++++++++++++++ internal/handler/cooldown_patterns_test.go | 50 ++++++ internal/handler/denylist_test.go | 2 +- internal/handler/handler.go | 10 +- internal/server/cooldown_patterns_test.go | 27 ++++ internal/server/server.go | 7 +- internal/server/server_test.go | 33 ++++ 12 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 internal/cooldownpolicy/policy.go create mode 100644 internal/cooldownpolicy/policy_test.go create mode 100644 internal/handler/cooldown_patterns_test.go create mode 100644 internal/server/cooldown_patterns_test.go diff --git a/config.example.yaml b/config.example.yaml index 44a3bfc3..49b81efd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -332,6 +332,10 @@ cooldown: # "pkg:npm/lodash": "0" # "pkg:npm/@babel/core": "14d" + # Per-package glob overrides, after exact packages and before ecosystems. + # package_patterns: + # "pkg:npm/@example/*": "0" + # Exact versions to deny, independently of cooldown and scanning. # Metadata filtering: npm, PyPI and Cargo. Shared artifact downloads, including # cache hits, are blocked with 403; signed APT metadata is left unchanged. diff --git a/docs/configuration.md b/docs/configuration.md index ed5ad404..27fd3a35 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -458,6 +458,8 @@ cooldown: packages: "pkg:npm/lodash": "0" "pkg:npm/@babel/core": "14d" + package_patterns: + "pkg:npm/@example/*": "0" ``` | Config | Environment | Description | @@ -465,12 +467,17 @@ cooldown: | `cooldown.default` | `PROXY_COOLDOWN_DEFAULT` | Global default cooldown | | `cooldown.ecosystems` | - | Per-ecosystem overrides | | `cooldown.packages` | - | Per-package overrides (keyed by PURL) | +| `cooldown.package_patterns` | - | Per-package glob overrides (keyed by PURL glob) | Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable. Package PURL keys are normalized to canonical form before matching, so `pkg:npm/@babel/core` and `pkg:npm/%40babel/core` are equivalent, as are `pkg:pypi/Django` and `pkg:pypi/django`. If both forms configure the same package, the canonical entry wins. -Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages. +`package_patterns` uses Go path globs against canonical, versionless PURLs. `*` and `?` do not cross `/` separators. For example, `"pkg:npm/@example/*"` matches packages under the `@example` npm scope. Patterns accept `@` as an alias for `%40`; other characters must use their canonical PURL form. Equivalent patterns with different durations are rejected at startup. Equal durations, such as `1d` and `24h`, are accepted. + +Exact `packages` entries take precedence over patterns. When several patterns match, longer patterns win after excluding `*` and `?` from the length. Ties use lexical order of the normalized patterns. + +Resolution order: exact package override, then package pattern, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted package families. Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata. diff --git a/internal/config/config.go b/internal/config/config.go index 05ed6d2f..54f826f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -192,6 +192,10 @@ type CooldownConfig struct { // Packages overrides the cooldown for specific packages (keyed by PURL). // Valid PURL keys are normalized to canonical form before use. Packages map[string]string `json:"packages" yaml:"packages"` + + // PackagePatterns overrides the cooldown for packages whose PURLs match a glob. + // Exact package overrides take precedence over matching patterns. + PackagePatterns map[string]string `json:"package_patterns" yaml:"package_patterns"` } // NormalizedPackages returns a copy of the package overrides with valid PURL diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 20069704..b1818cea 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -562,6 +562,8 @@ cooldown: packages: "pkg:npm/lodash": "0" "pkg:npm/@babel/core": "14d" + package_patterns: + "pkg:npm/@example/*": "0" ` if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("writing config file: %v", err) @@ -590,6 +592,9 @@ cooldown: if got := cfg.Cooldown.NormalizedPackages()["pkg:npm/%40babel/core"]; got != "14d" { t.Errorf("normalized Cooldown.Packages[@babel/core] = %q, want %q", got, "14d") } + if cfg.Cooldown.PackagePatterns["pkg:npm/@example/*"] != "0" { + t.Errorf("Cooldown.PackagePatterns[example] = %q, want %q", cfg.Cooldown.PackagePatterns["pkg:npm/@example/*"], "0") + } } func TestCooldownConfigNormalizedPackages(t *testing.T) { diff --git a/internal/cooldownpolicy/policy.go b/internal/cooldownpolicy/policy.go new file mode 100644 index 00000000..5741e1d5 --- /dev/null +++ b/internal/cooldownpolicy/policy.go @@ -0,0 +1,101 @@ +// Package cooldownpolicy applies package-pattern overrides to cooldown checks. +package cooldownpolicy + +import ( + "fmt" + "path" + "sort" + "strings" + "time" + + "github.com/git-pkgs/cooldown" +) + +// Policy applies exact PURL overrides before package-pattern overrides. +type Policy struct { + base *cooldown.Config + patterns []pattern + enabled bool +} + +type pattern struct { + glob string + duration time.Duration +} + +// New creates a Policy using the supplied exact and pattern overrides. +func New(base *cooldown.Config, packagePatterns map[string]string) (*Policy, error) { + if base == nil { + base = &cooldown.Config{} + } + + keys := make([]string, 0, len(packagePatterns)) + for glob := range packagePatterns { + keys = append(keys, glob) + } + sort.Strings(keys) + patterns := make([]pattern, 0, len(packagePatterns)) + seen := make(map[string]pattern) + enabled := base.Enabled() + for _, glob := range keys { + value := packagePatterns[glob] + canonicalGlob := strings.ReplaceAll(glob, "@", "%40") + if _, err := path.Match(canonicalGlob, ""); err != nil { + return nil, fmt.Errorf("invalid cooldown package pattern %q: %w", glob, err) + } + duration, err := cooldown.ParseDuration(value) + if err != nil { + return nil, fmt.Errorf("invalid cooldown duration for package pattern %q: %w", glob, err) + } + if previous, exists := seen[canonicalGlob]; exists { + if previous.duration != duration { + return nil, fmt.Errorf("conflicting cooldown package patterns %q and %q", previous.glob, glob) + } + continue + } + seen[canonicalGlob] = pattern{glob: glob, duration: duration} + patterns = append(patterns, pattern{glob: canonicalGlob, duration: duration}) + enabled = enabled || duration > 0 + } + sort.Slice(patterns, func(i, j int) bool { + left, right := literalLength(patterns[i].glob), literalLength(patterns[j].glob) + if left != right { + return left > right + } + return patterns[i].glob < patterns[j].glob + }) + + return &Policy{base: base, patterns: patterns, enabled: enabled}, nil +} + +func literalLength(glob string) int { + return len(glob) - strings.Count(glob, "*") - strings.Count(glob, "?") +} + +// For returns the duration, with exact overrides taking precedence over patterns. +func (p *Policy) For(ecosystem, packagePURL string) time.Duration { + if _, exact := p.base.Packages[packagePURL]; exact { + return p.base.For(ecosystem, packagePURL) + } + + for _, candidate := range p.patterns { + matched, _ := path.Match(candidate.glob, packagePURL) + if !matched { + continue + } + return candidate.duration + } + + return p.base.For(ecosystem, packagePURL) +} + +// IsAllowed reports whether the package version has completed its cooldown. +func (p *Policy) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool { + duration := p.For(ecosystem, packagePURL) + return duration == 0 || publishedAt.IsZero() || time.Since(publishedAt) >= duration +} + +// Enabled reports whether any configured cooldown can filter a package version. +func (p *Policy) Enabled() bool { + return p.enabled +} diff --git a/internal/cooldownpolicy/policy_test.go b/internal/cooldownpolicy/policy_test.go new file mode 100644 index 00000000..bc1600e9 --- /dev/null +++ b/internal/cooldownpolicy/policy_test.go @@ -0,0 +1,167 @@ +package cooldownpolicy + +import ( + "strings" + "testing" + "time" + + "github.com/git-pkgs/cooldown" +) + +func TestPatternOverride(t *testing.T) { + policy, err := New(&cooldown.Config{ + Default: "7d", + Ecosystems: map[string]string{"npm": "7d"}, + }, map[string]string{ + "pkg:npm/@example/*": "0", + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + if !policy.IsAllowed("npm", "pkg:npm/%40example/widget", time.Now()) { + t.Fatal("matching package pattern should disable cooldown") + } + if policy.IsAllowed("npm", "pkg:npm/public-package", time.Now()) { + t.Fatal("non-matching package should use ecosystem cooldown") + } +} + +func TestExactOverrideTakesPrecedenceOverPattern(t *testing.T) { + purl := "pkg:npm/%40example/widget" + policy, err := New(&cooldown.Config{ + Default: "7d", + Packages: map[string]string{purl: "2d"}, + }, map[string]string{ + "pkg:npm/@example/*": "0", + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + if policy.IsAllowed("npm", purl, time.Now()) { + t.Fatal("exact package override should take precedence over pattern") + } +} + +func TestMoreSpecificPatternTakesPrecedence(t *testing.T) { + policy, err := New(&cooldown.Config{Default: "7d"}, map[string]string{ + "pkg:npm/@example/*": "0", + "pkg:npm/@example/critical": "2d", + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + + if policy.IsAllowed("npm", "pkg:npm/%40example/critical", time.Now()) { + t.Fatal("more specific pattern should take precedence") + } +} + +func TestNewRejectsInvalidPattern(t *testing.T) { + if _, err := New(&cooldown.Config{}, map[string]string{"pkg:npm/[": "0"}); err == nil { + t.Fatal("New should reject an invalid package pattern") + } +} + +func TestNormalizedPatternCollisions(t *testing.T) { + for _, duration := range []string{"0", "24h"} { + t.Run(duration, func(t *testing.T) { + for range 20 { + policy, err := New(nil, map[string]string{ + "pkg:npm/@example/*": "1d", + "pkg:npm/%40example/*": duration, + }) + if duration == "0" { + if err == nil || !strings.Contains(err.Error(), `conflicting cooldown package patterns "pkg:npm/%40example/*" and "pkg:npm/@example/*"`) { + t.Fatalf("conflict error = %v", err) + } + continue + } + if err != nil { + t.Fatal(err) + } + if got := policy.For("npm", "pkg:npm/%40example/widget"); got != 24*time.Hour { + t.Fatalf("duration = %s", got) + } + } + }) + } +} + +func TestForPrecedence(t *testing.T) { + policy, err := New(&cooldown.Config{ + Default: "1h", + Ecosystems: map[string]string{"npm": "2h"}, + Packages: map[string]string{"pkg:npm/%40example/exact": "0"}, + }, map[string]string{ + "pkg:npm/@example/*": "3h", + "pkg:npm/@example/specific*": "4h", + "pkg:npm/@example/ab*": "5h", + "pkg:npm/@example/a*c": "6h", + }) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + ecosystem, purl string + want time.Duration + }{ + {"npm", "pkg:npm/%40example/exact", 0}, + {"npm", "pkg:npm/%40example/widget", 3 * time.Hour}, + {"npm", "pkg:npm/%40example/specific-widget", 4 * time.Hour}, + {"npm", "pkg:npm/%40example/abc", 6 * time.Hour}, + {"npm", "pkg:npm/other", 2 * time.Hour}, + {"npm", "pkg:npm/%40example/nested/package", 2 * time.Hour}, + {"cargo", "pkg:cargo/serde", time.Hour}, + } { + t.Run(tc.purl, func(t *testing.T) { + if got := policy.For(tc.ecosystem, tc.purl); got != tc.want { + t.Errorf("For = %s, want %s", got, tc.want) + } + }) + } +} + +func TestPatternOnlyPolicy(t *testing.T) { + policy, err := New(nil, map[string]string{"pkg:nuget/example.*": "24h"}) + if err != nil { + t.Fatal(err) + } + if !policy.Enabled() { + t.Fatal("pattern-only policy should be enabled") + } + for _, tc := range []struct { + published time.Time + allowed bool + }{ + {time.Now().Add(-time.Hour), false}, + {time.Now().Add(-48 * time.Hour), true}, + {time.Time{}, true}, + } { + if got := policy.IsAllowed("nuget", "pkg:nuget/example.widget", tc.published); got != tc.allowed { + t.Errorf("published=%s: allowed=%t, want %t", tc.published, got, tc.allowed) + } + } + if !policy.IsAllowed("nuget", "pkg:nuget/other", time.Now()) { + t.Error("unmatched package should have no cooldown") + } +} + +func TestDisabledPolicy(t *testing.T) { + for _, patterns := range []map[string]string{nil, {"pkg:npm/@example/*": "0"}} { + policy, err := New(nil, patterns) + if err != nil { + t.Fatal(err) + } + if policy.Enabled() { + t.Error("zero cooldown should be disabled") + } + } +} + +func TestNewRejectsInvalidDuration(t *testing.T) { + if _, err := New(nil, map[string]string{"pkg:npm/*": "invalid"}); err == nil { + t.Fatal("invalid duration accepted") + } +} diff --git a/internal/handler/cooldown_patterns_test.go b/internal/handler/cooldown_patterns_test.go new file mode 100644 index 00000000..dba6c285 --- /dev/null +++ b/internal/handler/cooldown_patterns_test.go @@ -0,0 +1,50 @@ +package handler + +import ( + "net/http" + "strings" + "testing" + + "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/proxy/internal/cooldownpolicy" +) + +func TestNuGetCooldownPackagePatterns(t *testing.T) { + for _, tc := range []struct { + name, defaultDuration, pattern string + exact map[string]string + blocked bool + }{ + {"pattern enables cooldown", "", "14d", nil, true}, + {"pattern exempts package", "14d", "0", nil, false}, + {"exact overrides exemption", "", "0", map[string]string{"pkg:nuget/testpkg": "14d"}, true}, + {"exact exempts package", "", "14d", map[string]string{"pkg:nuget/testpkg": "0"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + p, db, store, fetcher := setupTestProxy(t) + policy, err := cooldownpolicy.New(&cooldown.Config{Default: tc.defaultDuration, Packages: tc.exact}, map[string]string{"pkg:nuget/test*": tc.pattern}) + if err != nil { + t.Fatal(err) + } + p.Cooldown = policy + seedPackage(t, db, store, "nuget", "testpkg", "2.0.0", "testpkg.2.0.0.nupkg", "cached package") + requests := 0 + upstream := newNuGetCooldownUpstream(t, &requests) + defer upstream.Close() + p.HTTPClient = upstream.Client() + h := NewNuGetHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL) + list := nugetGet(t, h.Routes(), "/v3-flatcontainer/TestPkg/index.json", http.StatusOK) + if strings.Contains(list.Body.String(), "2.0.0") == tc.blocked { + t.Fatalf("incorrect version list: %s", list.Body.String()) + } + status := http.StatusOK + if tc.blocked { + status = http.StatusNotFound + } + nugetGet(t, h.Routes(), "/v3-flatcontainer/TestPkg/2.0.0/testpkg.2.0.0.nupkg", status) + if fetcher.fetchCalled { + t.Fatal("cached or withheld download fetched upstream artifact") + } + }) + } +} diff --git a/internal/handler/denylist_test.go b/internal/handler/denylist_test.go index 5034a403..383f95c7 100644 --- a/internal/handler/denylist_test.go +++ b/internal/handler/denylist_test.go @@ -98,7 +98,7 @@ func TestCargoDenylistWithoutTimestamps(t *testing.T) { setTestDenylist(t, p, "pkg:cargo/demo@1.0.0") h := &CargoHandler{proxy: p} input := "{\"name\":\"demo\",\"vers\":\"1.0.0\"}\n{\"name\":\"demo\",\"vers\":\"2.0.0\"}\n" - for _, cd := range []*cooldown.Config{nil, {Default: "3d"}} { + for _, cd := range []CooldownPolicy{nil, &cooldown.Config{Default: "3d"}} { p.Cooldown = cd w := httptest.NewRecorder() h.applyCooldownFiltering(w, []byte(input)) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index e5ccd61f..8bfce21b 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -17,7 +17,6 @@ import ( "time" "github.com/git-pkgs/artifacts" - "github.com/git-pkgs/cooldown" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/denylist" "github.com/git-pkgs/proxy/internal/metrics" @@ -156,7 +155,7 @@ type Proxy struct { Fetcher fetch.FetcherInterface Resolver *fetch.Resolver Logger *slog.Logger - Cooldown *cooldown.Config + Cooldown CooldownPolicy Denylist *denylist.Policy CacheMetadata bool MetadataTTL time.Duration @@ -208,6 +207,13 @@ type Proxy struct { rewrites *rewriteCache } +// CooldownPolicy supplies version-age filtering and package-specific durations. +type CooldownPolicy interface { + IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool + For(ecosystem, packagePURL string) time.Duration + Enabled() bool +} + // NewProxy creates a new Proxy with the given dependencies. func NewProxy(db *database.DB, store storage.Storage, fetcher fetch.FetcherInterface, resolver *fetch.Resolver, logger *slog.Logger) *Proxy { if logger == nil { diff --git a/internal/server/cooldown_patterns_test.go b/internal/server/cooldown_patterns_test.go new file mode 100644 index 00000000..5fe0102d --- /dev/null +++ b/internal/server/cooldown_patterns_test.go @@ -0,0 +1,27 @@ +package server + +import ( + "strings" + "testing" +) + +func TestStartRejectsInvalidCooldownPatterns(t *testing.T) { + for _, tc := range []struct { + name string + patterns map[string]string + message string + }{ + {"glob", map[string]string{"pkg:npm/[": "0"}, "invalid cooldown package pattern"}, + {"duration", map[string]string{"pkg:npm/*": "invalid"}, "invalid cooldown duration"}, + {"aliases", map[string]string{"pkg:npm/@example/*": "0", "pkg:npm/%40example/*": "7d"}, "conflicting cooldown package patterns"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestServer(t) + defer s.close() + s.server.cfg.Cooldown.PackagePatterns = tc.patterns + if err := s.server.Start(); err == nil || !strings.Contains(err.Error(), tc.message) { + t.Fatalf("Start error = %v, want %q", err, tc.message) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 24839091..c5e08f1a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -68,6 +68,7 @@ import ( swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" "github.com/git-pkgs/proxy/internal/accesslog" "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/proxy/internal/cooldownpolicy" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/denylist" "github.com/git-pkgs/proxy/internal/enrichment" @@ -242,7 +243,11 @@ func (s *Server) serve(listener net.Listener) error { proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger) proxy.HTTPClient = &metadataClient proxy.AuthForURL = s.authForURL - proxy.Cooldown = cd + cooldownPolicy, err := cooldownpolicy.New(cd, s.cfg.Cooldown.PackagePatterns) + if err != nil { + return fmt.Errorf("configuring cooldown policy: %w", err) + } + proxy.Cooldown = cooldownPolicy policy, err := denylist.New(s.cfg.Denylist.Packages) if err != nil { return fmt.Errorf("configuring denylist: %w", err) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 57b80808..06b43fec 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -196,9 +196,15 @@ func testStartUsesConfiguredLoopbackUpstreams(t *testing.T) { case "/pypi/simple/ruff/": w.Header().Set("Content-Type", "application/vnd.pypi.simple.v1+json") _, _ = io.WriteString(w, `{"meta":{"api-version":"1.4"},"name":"ruff","files":[{"filename":"ruff-1.0.0.tar.gz","url":"ruff-1.0.0.tar.gz"},{"filename":"ruff-2.0.0.tar.gz","url":"ruff-2.0.0.tar.gz"}]}`) + case "/pypi/pypi/ruff/json": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"releases":{}}`) case "/v2/library/demo/manifests/latest": w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") _, _ = io.WriteString(w, `{"schemaVersion":2}`) + case "/npm/@example/widget", "/npm/@example/exact", "/npm/other": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"time":{"1.0.0":"2020-01-01T00:00:00Z","2.0.0":%q},"versions":{"1.0.0":{},"2.0.0":{}}}`, time.Now().Add(-time.Hour).Format(time.RFC3339)) default: t.Errorf("unexpected upstream path: %q", r.URL.Path) http.NotFound(w, r) @@ -223,6 +229,12 @@ func testStartUsesConfiguredLoopbackUpstreams(t *testing.T) { cfg.Upstream.PyPIDownload = upstream.URL + "/pypi" cfg.Upstream.OCIDefault = upstream.URL cfg.Upstream.AllowLoopback = true + cfg.Upstream.NPM = upstream.URL + "/npm" + cfg.Cooldown = config.CooldownConfig{ + Default: "7d", + Packages: map[string]string{"pkg:npm/@example/exact": "7d"}, + PackagePatterns: map[string]string{"pkg:npm/@example/*": "0"}, + } cfg.Denylist.Packages = []string{"pkg:pypi/ruff@1.0.0"} if err := cfg.Validate(); err != nil { t.Fatalf("validating config: %v", err) @@ -294,6 +306,27 @@ func testStartUsesConfiguredLoopbackUpstreams(t *testing.T) { if !strings.Contains(string(body), `"schemaVersion":2`) { t.Fatalf("OCI response body = %s, want manifest", body) } + assertCooldownPatternMetadata(t, client, cfg.BaseURL) +} + +func assertCooldownPatternMetadata(t *testing.T, client *http.Client, baseURL string) { + t.Helper() + for _, name := range []string{"@example/widget", "@example/exact", "other"} { + resp, err := client.Get(baseURL + "/npm/" + url.PathEscape(name)) + if err != nil { + t.Fatal(err) + } + var metadata struct{ Versions map[string]json.RawMessage } + err = json.NewDecoder(resp.Body).Decode(&metadata) + _ = resp.Body.Close() + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("metadata for %s: status %d, error %v", name, resp.StatusCode, err) + } + _, recent := metadata.Versions["2.0.0"] + if recent != (name == "@example/widget") || metadata.Versions["1.0.0"] == nil { + t.Errorf("pattern policy for %s: versions %v", name, metadata.Versions) + } + } } // TestScanFetchRouteNotMountedWhenScanningDisabled verifies the internal From 0b760e7578028c98b543b7bdaf42f91a5a7259c4 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 4 Oct 2026 18:28:30 +0100 Subject: [PATCH 2/3] Expose full cooldown decisions through pattern policy --- internal/cooldownpolicy/policy.go | 24 +++++++--- internal/cooldownpolicy/policy_test.go | 33 +++++++++++++ internal/handler/cooldown_patterns_test.go | 55 ++++++++++++++++++++++ internal/handler/handler.go | 2 + 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/internal/cooldownpolicy/policy.go b/internal/cooldownpolicy/policy.go index 5741e1d5..22fbc8dc 100644 --- a/internal/cooldownpolicy/policy.go +++ b/internal/cooldownpolicy/policy.go @@ -21,6 +21,7 @@ type Policy struct { type pattern struct { glob string duration time.Duration + config *cooldown.Config } // New creates a Policy using the supplied exact and pattern overrides. @@ -54,8 +55,9 @@ func New(base *cooldown.Config, packagePatterns map[string]string) (*Policy, err continue } seen[canonicalGlob] = pattern{glob: glob, duration: duration} - patterns = append(patterns, pattern{glob: canonicalGlob, duration: duration}) - enabled = enabled || duration > 0 + config := &cooldown.Config{Default: value} + enabled = config.Enabled() || enabled + patterns = append(patterns, pattern{glob: canonicalGlob, duration: duration, config: config}) } sort.Slice(patterns, func(i, j int) bool { left, right := literalLength(patterns[i].glob), literalLength(patterns[j].glob) @@ -74,8 +76,12 @@ func literalLength(glob string) int { // For returns the duration, with exact overrides taking precedence over patterns. func (p *Policy) For(ecosystem, packagePURL string) time.Duration { + return p.configFor(packagePURL).For(ecosystem, packagePURL) +} + +func (p *Policy) configFor(packagePURL string) *cooldown.Config { if _, exact := p.base.Packages[packagePURL]; exact { - return p.base.For(ecosystem, packagePURL) + return p.base } for _, candidate := range p.patterns { @@ -83,16 +89,20 @@ func (p *Policy) For(ecosystem, packagePURL string) time.Duration { if !matched { continue } - return candidate.duration + return candidate.config } - return p.base.For(ecosystem, packagePURL) + return p.base } // IsAllowed reports whether the package version has completed its cooldown. func (p *Policy) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool { - duration := p.For(ecosystem, packagePURL) - return duration == 0 || publishedAt.IsZero() || time.Since(publishedAt) >= duration + return p.Evaluate(ecosystem, packagePURL, publishedAt, time.Now()).Allowed +} + +// Evaluate returns the cooldown decision at the supplied evaluation time. +func (p *Policy) Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) cooldown.Decision { + return p.configFor(packagePURL).Evaluate(ecosystem, packagePURL, publishedAt, evaluatedAt) } // Enabled reports whether any configured cooldown can filter a package version. diff --git a/internal/cooldownpolicy/policy_test.go b/internal/cooldownpolicy/policy_test.go index bc1600e9..25999ae8 100644 --- a/internal/cooldownpolicy/policy_test.go +++ b/internal/cooldownpolicy/policy_test.go @@ -165,3 +165,36 @@ func TestNewRejectsInvalidDuration(t *testing.T) { t.Fatal("invalid duration accepted") } } + +func TestEvaluate(t *testing.T) { + policy, err := New(&cooldown.Config{ + Default: "48h", + Ecosystems: map[string]string{"npm": "72h"}, + Packages: map[string]string{"pkg:npm/%40example/exact": "0"}, + }, map[string]string{"pkg:npm/@example/*": "7d"}) + if err != nil { + t.Fatal(err) + } + published := time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC) + eligible := published.Add(7 * 24 * time.Hour) + for _, tc := range []struct { + name, ecosystem, purl string + published, at time.Time + want cooldown.Decision + }{ + {"waiting", "npm", "pkg:npm/%40example/widget", published, eligible.Add(-time.Nanosecond), cooldown.Decision{Cooldown: 7 * 24 * time.Hour, AvailableAt: eligible, Reason: cooldown.ReasonWaiting}}, + {"boundary", "npm", "pkg:npm/%40example/widget", published, eligible, cooldown.Decision{Allowed: true, Cooldown: 7 * 24 * time.Hour, AvailableAt: eligible, Reason: cooldown.ReasonElapsed}}, + {"elapsed", "npm", "pkg:npm/%40example/widget", published, eligible.Add(time.Hour), cooldown.Decision{Allowed: true, Cooldown: 7 * 24 * time.Hour, AvailableAt: eligible, Reason: cooldown.ReasonElapsed}}, + {"unknown publication", "npm", "pkg:npm/%40example/widget", time.Time{}, eligible, cooldown.Decision{Allowed: true, Cooldown: 7 * 24 * time.Hour, Reason: cooldown.ReasonUnknownPublicationTime}}, + {"exact exemption", "npm", "pkg:npm/%40example/exact", published, published, cooldown.Decision{Allowed: true, AvailableAt: published, Reason: cooldown.ReasonDisabled}}, + {"exemption without publication", "npm", "pkg:npm/%40example/exact", time.Time{}, eligible, cooldown.Decision{Allowed: true, Reason: cooldown.ReasonUnknownPublicationTime}}, + {"ecosystem fallback", "npm", "pkg:npm/other", published, published, cooldown.Decision{Cooldown: 72 * time.Hour, AvailableAt: published.Add(72 * time.Hour), Reason: cooldown.ReasonWaiting}}, + {"global fallback", "cargo", "pkg:cargo/serde", published, published, cooldown.Decision{Cooldown: 48 * time.Hour, AvailableAt: published.Add(48 * time.Hour), Reason: cooldown.ReasonWaiting}}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := policy.Evaluate(tc.ecosystem, tc.purl, tc.published, tc.at); got != tc.want { + t.Errorf("Evaluate = %+v, want %+v", got, tc.want) + } + }) + } +} diff --git a/internal/handler/cooldown_patterns_test.go b/internal/handler/cooldown_patterns_test.go index dba6c285..83034cf8 100644 --- a/internal/handler/cooldown_patterns_test.go +++ b/internal/handler/cooldown_patterns_test.go @@ -1,9 +1,13 @@ package handler import ( + "fmt" + "io" "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/git-pkgs/cooldown" "github.com/git-pkgs/proxy/internal/cooldownpolicy" @@ -48,3 +52,54 @@ func TestNuGetCooldownPackagePatterns(t *testing.T) { }) } } + +func TestNPMCooldownPatternExemptionRespectsDenylist(t *testing.T) { + p, db, store, fetcher := setupTestProxy(t) + var err error + p.Cooldown, err = cooldownpolicy.New(&cooldown.Config{Default: "7d"}, map[string]string{"pkg:npm/@example/*": "0"}) + if err != nil { + t.Fatal(err) + } + setTestDenylist(t, p, "pkg:npm/@example/widget@1.0.0") + published := time.Now().Add(-time.Hour) + for _, version := range []string{"1.0.0", "2.0.0"} { + seedPackage(t, db, store, "npm", "@example/widget", version, "widget-"+version+".tgz", "cached package") + if err := db.SetVersionPublishedAt("pkg:npm/%40example/widget@"+version, "pkg:npm/%40example/widget", published); err != nil { + t.Fatal(err) + } + } + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/@example/widget" { + t.Errorf("unexpected upstream request: %s", r.URL) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"name":"@example/widget","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{},"2.0.0":{}},"time":{"1.0.0":%q,"2.0.0":%q}}`, published.Format(time.RFC3339), published.Format(time.RFC3339)) + })) + defer upstream.Close() + p.HTTPClient = upstream.Client() + h := NewNPMHandler(p, "http://proxy.test", upstream.URL) + server := httptest.NewServer(h.Routes()) + defer server.Close() + for _, tc := range []struct { + path string + status int + contains, absent string + }{ + {"/@example%2Fwidget", http.StatusOK, `"latest":"2.0.0"`, "1.0.0"}, + {"/@example%2Fwidget/-/widget-1.0.0.tgz", http.StatusForbidden, "denylist", "cached package"}, + {"/@example%2Fwidget/-/widget-2.0.0.tgz", http.StatusOK, "cached package", "denylist"}, + } { + response, err := server.Client().Get(server.URL + tc.path) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil || response.StatusCode != tc.status || !strings.Contains(string(body), tc.contains) || strings.Contains(string(body), tc.absent) { + t.Errorf("GET %s: status=%d body=%s error=%v", tc.path, response.StatusCode, body, err) + } + } + if fetcher.fetchCalled { + t.Error("cached or denied downloads fetched an upstream artifact") + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 8bfce21b..20dce9ab 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -17,6 +17,7 @@ import ( "time" "github.com/git-pkgs/artifacts" + "github.com/git-pkgs/cooldown" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/denylist" "github.com/git-pkgs/proxy/internal/metrics" @@ -210,6 +211,7 @@ type Proxy struct { // CooldownPolicy supplies version-age filtering and package-specific durations. type CooldownPolicy interface { IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool + Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) cooldown.Decision For(ecosystem, packagePURL string) time.Duration Enabled() bool } From 507c74226544342d77ef3bea0301bc4eab6cf621 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 4 Oct 2026 19:02:48 +0100 Subject: [PATCH 3/3] Reject unsafe cooldown pattern syntax --- docs/configuration.md | 2 +- internal/cooldownpolicy/policy.go | 3 +++ internal/cooldownpolicy/policy_test.go | 27 +++++++++++++++++++++-- internal/server/cooldown_patterns_test.go | 3 +++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 27fd3a35..8261ebae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -473,7 +473,7 @@ Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to Package PURL keys are normalized to canonical form before matching, so `pkg:npm/@babel/core` and `pkg:npm/%40babel/core` are equivalent, as are `pkg:pypi/Django` and `pkg:pypi/django`. If both forms configure the same package, the canonical entry wins. -`package_patterns` uses Go path globs against canonical, versionless PURLs. `*` and `?` do not cross `/` separators. For example, `"pkg:npm/@example/*"` matches packages under the `@example` npm scope. Patterns accept `@` as an alias for `%40`; other characters must use their canonical PURL form. Equivalent patterns with different durations are rejected at startup. Equal durations, such as `1d` and `24h`, are accepted. +`package_patterns` matches canonical, versionless PURLs using `*` for zero or more characters and `?` for one character. Neither wildcard crosses `/` separators. Character classes (`[...]`) and backslash escapes are rejected at startup. For example, `"pkg:npm/@example/*"` matches packages under the `@example` npm scope. Patterns accept `@` as an alias for `%40`; other characters must use their canonical PURL form. Equivalent patterns with different durations are rejected at startup. Equal durations, such as `1d` and `24h`, are accepted. Exact `packages` entries take precedence over patterns. When several patterns match, longer patterns win after excluding `*` and `?` from the length. Ties use lexical order of the normalized patterns. diff --git a/internal/cooldownpolicy/policy.go b/internal/cooldownpolicy/policy.go index 22fbc8dc..096c4a4a 100644 --- a/internal/cooldownpolicy/policy.go +++ b/internal/cooldownpolicy/policy.go @@ -40,6 +40,9 @@ func New(base *cooldown.Config, packagePatterns map[string]string) (*Policy, err enabled := base.Enabled() for _, glob := range keys { value := packagePatterns[glob] + if strings.ContainsAny(glob, "[\\") { + return nil, fmt.Errorf("invalid cooldown package pattern %q: character classes and escapes are not supported", glob) + } canonicalGlob := strings.ReplaceAll(glob, "@", "%40") if _, err := path.Match(canonicalGlob, ""); err != nil { return nil, fmt.Errorf("invalid cooldown package pattern %q: %w", glob, err) diff --git a/internal/cooldownpolicy/policy_test.go b/internal/cooldownpolicy/policy_test.go index 25999ae8..9e956c14 100644 --- a/internal/cooldownpolicy/policy_test.go +++ b/internal/cooldownpolicy/policy_test.go @@ -59,8 +59,31 @@ func TestMoreSpecificPatternTakesPrecedence(t *testing.T) { } func TestNewRejectsInvalidPattern(t *testing.T) { - if _, err := New(&cooldown.Config{}, map[string]string{"pkg:npm/[": "0"}); err == nil { - t.Fatal("New should reject an invalid package pattern") + for _, glob := range []string{"pkg:npm/[", "pkg:npm/[@a]*", "pkg:npm/[abcdef]*", `pkg:npm/\*`, `pkg:npm/\@example/*`} { + t.Run(glob, func(t *testing.T) { + if _, err := New(nil, map[string]string{glob: "0"}); err == nil || !strings.Contains(err.Error(), "character classes and escapes are not supported") { + t.Fatalf("unsupported pattern %q: error = %v", glob, err) + } + }) + } +} + +func TestQuestionMarkPattern(t *testing.T) { + policy, err := New(&cooldown.Config{Default: "7d"}, map[string]string{"pkg:npm/@example/widget-?": "0"}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + purl string + want time.Duration + }{ + {"pkg:npm/%40example/widget-a", 0}, + {"pkg:npm/%40example/widget-ab", 7 * 24 * time.Hour}, + {"pkg:npm/%40example/widget-/", 7 * 24 * time.Hour}, + } { + if got := policy.For("npm", tc.purl); got != tc.want { + t.Errorf("For(%q) = %s, want %s", tc.purl, got, tc.want) + } } } diff --git a/internal/server/cooldown_patterns_test.go b/internal/server/cooldown_patterns_test.go index 5fe0102d..0587ba09 100644 --- a/internal/server/cooldown_patterns_test.go +++ b/internal/server/cooldown_patterns_test.go @@ -12,6 +12,9 @@ func TestStartRejectsInvalidCooldownPatterns(t *testing.T) { message string }{ {"glob", map[string]string{"pkg:npm/[": "0"}, "invalid cooldown package pattern"}, + {"class containing scope alias", map[string]string{"pkg:npm/[@a]*": "0"}, "character classes and escapes are not supported"}, + {"character class", map[string]string{"pkg:npm/[abcdef]*": "0"}, "character classes and escapes are not supported"}, + {"escaped wildcard", map[string]string{`pkg:npm/\*`: "0"}, "character classes and escapes are not supported"}, {"duration", map[string]string{"pkg:npm/*": "invalid"}, "invalid cooldown duration"}, {"aliases", map[string]string{"pkg:npm/@example/*": "0", "pkg:npm/%40example/*": "7d"}, "conflicting cooldown package patterns"}, } {