Skip to content
Closed
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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A small, ecosystem-agnostic version-age filter for package-manager tools. Hides versions published too recently so the community has time to spot malicious releases before they're pulled into projects.

Cross-ecosystem by construction: the same `Config` shape covers npm, PyPI, Cargo, RubyGems, Composer, Conda, Hex, NuGet, Pub, and any future ecosystem. Resolution order is package-PURL > ecosystem-name > global default, so a single config can express a strict default with targeted opt-outs.
Cross-ecosystem by construction: the same `Config` shape covers npm, PyPI, Cargo, RubyGems, Composer, Conda, Hex, NuGet, Pub, and any future ecosystem. Resolution order is package-PURL > package-PURL pattern > ecosystem-name > global default, so a single config can express a strict default with targeted opt-outs.

## Install

Expand All @@ -19,6 +19,9 @@ cfg := &cooldown.Config{
Packages: map[string]string{ // per-PURL override
"pkg:npm/htmx.org": "0", // 0 = disabled
},
PackagePatterns: map[string]string{ // per-PURL glob override
"pkg:npm/@example/*": "0",
},
}

if cfg.IsAllowed("npm", "pkg:npm/lodash", publishedAt) {
Expand All @@ -28,6 +31,12 @@ if cfg.IsAllowed("npm", "pkg:npm/lodash", publishedAt) {

`Config.For(ecosystem, purl)` returns the effective duration; useful when surfacing the policy to a UI. `Config.Enabled()` reports whether any cooldown is configured (cheap check before walking a large version set).

`PackagePatterns` uses Go path globs against versionless PURLs. For example,
`pkg:npm/@example/*` matches an npm scope. Exact `Packages` entries take
precedence over patterns. When multiple patterns match, the most specific
pattern wins; ties use lexical order. Scoped npm patterns accept `@` and match
canonical PURLs used by registry integrations.

Duration strings accept Go's standard formats (`48h`, `30m`, `1h30m`) plus a `d` suffix for days (`3d`). `0` disables the window.

## Why standalone
Expand Down
89 changes: 88 additions & 1 deletion cooldown.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cooldown

import (
"fmt"
"path"
"sort"
"strconv"
"strings"
"time"
Expand All @@ -24,12 +26,22 @@ type Config struct {
// Keys are PURLs (e.g., "pkg:npm/lodash", "pkg:npm/@babel/core").
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"`

defaultDuration time.Duration
ecosystemDurations map[string]time.Duration
packageDurations map[string]time.Duration
packagePatterns []packagePattern
parsed bool
}

type packagePattern struct {
glob string
duration time.Duration
}

// parse resolves all string durations into time.Duration values.
// Called lazily on first use.
func (c *Config) parse() {
Expand All @@ -51,22 +63,92 @@ func (c *Config) parse() {
d, _ := ParseDuration(v)
c.packageDurations[k] = d
}

c.packagePatterns = make([]packagePattern, 0, len(c.PackagePatterns))
for glob, value := range c.PackagePatterns {
if _, err := path.Match(glob, ""); err != nil {
continue
}
duration, err := ParseDuration(value)
if err != nil {
continue
}
c.packagePatterns = append(c.packagePatterns, packagePattern{glob: glob, duration: duration})
}
sort.Slice(c.packagePatterns, func(i, j int) bool {
left, right := literalLength(c.packagePatterns[i].glob), literalLength(c.packagePatterns[j].glob)
if left != right {
return left > right
}
return c.packagePatterns[i].glob < c.packagePatterns[j].glob
})
}

func literalLength(glob string) int {
// Count bytes that must match literally. This is used only for ordering patterns
// when multiple patterns match.
lit := 0
inClass := false
escaped := false
for i := 0; i < len(glob); i++ {
c := glob[i]
if escaped {
lit++
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if inClass {
if c == ']' {
inClass = false
}
continue
}
switch c {
case '*', '?':
// wildcard
case '[':
inClass = true
default:
lit++
}
}
return lit
}

// For returns the effective cooldown duration for a given ecosystem and package PURL.
// Resolution order: package override > ecosystem override > global default.
// Resolution order: package override > package pattern > ecosystem override >
// global default.
func (c *Config) For(ecosystem, packagePURL string) time.Duration {
c.parse()

if d, ok := c.packageDurations[packagePURL]; ok {
return d
}
for _, candidate := range c.packagePatterns {
if matchesPattern(candidate.glob, packagePURL) {
return candidate.duration
}
}
if d, ok := c.ecosystemDurations[ecosystem]; ok {
return d
}
return c.defaultDuration
}

func matchesPattern(glob, packagePURL string) bool {
matched, _ := path.Match(glob, packagePURL)
if matched {
return true
}
decoded := strings.ReplaceAll(packagePURL, "%40", "@")
matched, _ = path.Match(glob, decoded)
return matched
}

// IsAllowed returns true if a version with the given publish time has passed
// the cooldown period for this ecosystem/package.
func (c *Config) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool {
Expand Down Expand Up @@ -96,6 +178,11 @@ func (c *Config) Enabled() bool {
return true
}
}
for _, candidate := range c.packagePatterns {
if candidate.duration > 0 {
return true
}
}
return false
}

Expand Down
28 changes: 28 additions & 0 deletions cooldown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func TestConfigEnabled(t *testing.T) {
{"default only", Config{Default: "3d"}, true},
{"ecosystem only", Config{Ecosystems: map[string]string{"npm": "7d"}}, true},
{"package only", Config{Packages: map[string]string{"pkg:npm/x": "1d"}}, true},
{"package pattern only", Config{PackagePatterns: map[string]string{"pkg:npm/@example/*": "1d"}}, true},
{"all zero", Config{Default: "0", Ecosystems: map[string]string{"npm": "0"}}, false},
}

Expand All @@ -131,3 +132,30 @@ func TestConfigEnabled(t *testing.T) {
})
}
}

func TestConfigPackagePatterns(t *testing.T) {
now := time.Now()
cfg := Config{
Default: "7d",
PackagePatterns: map[string]string{
"pkg:npm/@example/*": "0",
"pkg:npm/@example/critical": "2d",
},
Packages: map[string]string{
"pkg:npm/@example/critical": "4d",
},
}

if got := cfg.For("npm", "pkg:npm/@example/widget"); got != 0 {
t.Errorf("scoped pattern duration = %v, want 0", got)
}
if got := cfg.For("npm", "pkg:npm/@example/critical"); got != 4*24*time.Hour {
t.Errorf("exact package duration = %v, want 4d", got)
}
if !cfg.IsAllowed("npm", "pkg:npm/@example/widget", now) {
t.Fatal("matching package pattern should disable cooldown")
}
if cfg.IsAllowed("npm", "pkg:npm/public-package", now) {
t.Fatal("non-matching package should use default cooldown")
}
}