Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,19 +458,26 @@ cooldown:
packages:
"pkg:npm/lodash": "0"
"pkg:npm/@babel/core": "14d"
package_patterns:
"pkg:npm/@example/*": "0"
```

| Config | Environment | Description |
|--------|-------------|-------------|
| `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` 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.

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.

Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
114 changes: 114 additions & 0 deletions internal/cooldownpolicy/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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
config *cooldown.Config
}

// 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]
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)
}
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}
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)
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 {
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 _, candidate := range p.patterns {
matched, _ := path.Match(candidate.glob, packagePURL)
if !matched {
continue
}
return candidate.config
}

return p.base
}

// IsAllowed reports whether the package version has completed its cooldown.
func (p *Policy) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool {
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.
func (p *Policy) Enabled() bool {
return p.enabled
}
Loading
Loading