diff --git a/docs/architecture.md b/docs/architecture.md index 81c41cf..85677b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -283,9 +283,9 @@ HTTP server setup, web UI, and API handlers. Prometheus metrics for cache performance, upstream latency, storage operations, and active requests. See the Monitoring section of the README for the full metric list. -### `internal/cooldown` +### Cooldown -Version age filtering for supply chain attack mitigation. Configurable at global, ecosystem, and per-package levels. Supported by npm, PyPI, pub.dev, and Composer handlers. +Version age filtering for supply chain attack mitigation, provided by [github.com/git-pkgs/cooldown](https://github.com/git-pkgs/cooldown). Configurable at global, ecosystem, and per-package levels. Supported by npm, PyPI, pub.dev, and Composer handlers. ### `internal/enrichment` diff --git a/go.mod b/go.mod index 87fd2db..02e6b41 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,10 @@ go 1.25.6 require ( github.com/CycloneDX/cyclonedx-go v0.10.0 github.com/git-pkgs/archives v0.3.0 + github.com/git-pkgs/cooldown v0.1.1 github.com/git-pkgs/enrichment v0.2.2 github.com/git-pkgs/purl v0.1.12 - github.com/git-pkgs/registries v0.5.1 + github.com/git-pkgs/registries v0.6.0 github.com/git-pkgs/spdx v0.1.3 github.com/git-pkgs/vers v0.2.5 github.com/git-pkgs/vulns v0.1.5 diff --git a/go.sum b/go.sum index 80df597..5fe9699 100644 --- a/go.sum +++ b/go.sum @@ -252,6 +252,8 @@ github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1 github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= github.com/git-pkgs/archives v0.3.0 h1:iXKyO83jEFub1PGEDlHmk2tQ7XeV5LySTc0sEkH3x78= github.com/git-pkgs/archives v0.3.0/go.mod h1:LTJ1iQVFA7otizWMOyiI82NYVmyBWAPRzwu/e30rcXU= +github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w= +github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E= github.com/git-pkgs/enrichment v0.2.2 h1:vaQu5vs3tjQB5JI0gzBrUCynUc9z3l5byPhgKFaNZrc= github.com/git-pkgs/enrichment v0.2.2/go.mod h1:5JWGmlHWcv5HQHUrctcpnRUNpEF5VAixD2z4zvqKejs= github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6Jt5ak7M= @@ -260,8 +262,8 @@ github.com/git-pkgs/pom v0.1.4 h1:C6st+XSbF75eKuwfdkDZZtYHoTcaWRIEQYar5VtszUo= github.com/git-pkgs/pom v0.1.4/go.mod h1:ufdMBe1lKzqOeP9IUb9NPZ458xKV8E8NvuyBMxOfwIk= github.com/git-pkgs/purl v0.1.12 h1:qCskrEU1LWQhCkIVZd992W5++Bsxazvx2Cx1/65qCvU= github.com/git-pkgs/purl v0.1.12/go.mod h1:ofp4mHsR0cUeVONQaf33n6Wxg2QTEvtUdRfCedI8ouA= -github.com/git-pkgs/registries v0.5.1 h1:UPE42CyZAsOfqO3N5bDelu28wS4Ifx/aOj0XZS4qYeI= -github.com/git-pkgs/registries v0.5.1/go.mod h1:BY0YW+V0WDGBMuDR2aSMR3NzOPFK4K+F3j6+ch+cq3M= +github.com/git-pkgs/registries v0.6.0 h1:ttQC8via9XAoLk9vqysf0K7uWl1bAyHPBWRBavRpAqs= +github.com/git-pkgs/registries v0.6.0/go.mod h1:BY0YW+V0WDGBMuDR2aSMR3NzOPFK4K+F3j6+ch+cq3M= github.com/git-pkgs/spdx v0.1.3 h1:YQou23mLfzbW//6JlHUuc5x1P5VNIIDSku5gvauf86I= github.com/git-pkgs/spdx v0.1.3/go.mod h1:4HGGWyC8tg4DjOhrtBTYl4Lu+5i2BFuauGX8zcVcYPg= github.com/git-pkgs/vers v0.2.5 h1:tDtUMik9Iw1lyPHdT5V6LXjLo9LsJc0xOawURz7ibQU= diff --git a/internal/cooldown/cooldown.go b/internal/cooldown/cooldown.go deleted file mode 100644 index f37a2b9..0000000 --- a/internal/cooldown/cooldown.go +++ /dev/null @@ -1,125 +0,0 @@ -package cooldown - -import ( - "fmt" - "strconv" - "strings" - "time" -) - -const hoursPerDay = 24 - -// Config holds cooldown settings for version filtering. -// Cooldown hides package versions published too recently, giving the community -// time to spot malicious releases before they're pulled into projects. -type Config struct { - // Default is the global default cooldown duration (e.g., "3d", "48h"). - Default string `json:"default" yaml:"default"` - - // Ecosystems overrides the default for specific ecosystems. - // Keys are ecosystem names (e.g., "npm", "pypi"). - Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"` - - // Packages overrides the cooldown for specific packages. - // Keys are PURLs (e.g., "pkg:npm/lodash", "pkg:npm/@babel/core"). - Packages map[string]string `json:"packages" yaml:"packages"` - - defaultDuration time.Duration - ecosystemDurations map[string]time.Duration - packageDurations map[string]time.Duration - parsed bool -} - -// parse resolves all string durations into time.Duration values. -// Called lazily on first use. -func (c *Config) parse() { - if c.parsed { - return - } - c.parsed = true - - c.defaultDuration, _ = ParseDuration(c.Default) - - c.ecosystemDurations = make(map[string]time.Duration, len(c.Ecosystems)) - for k, v := range c.Ecosystems { - d, _ := ParseDuration(v) - c.ecosystemDurations[k] = d - } - - c.packageDurations = make(map[string]time.Duration, len(c.Packages)) - for k, v := range c.Packages { - d, _ := ParseDuration(v) - c.packageDurations[k] = d - } -} - -// For returns the effective cooldown duration for a given ecosystem and package PURL. -// Resolution order: package override > ecosystem override > global default. -func (c *Config) For(ecosystem, packagePURL string) time.Duration { - c.parse() - - if d, ok := c.packageDurations[packagePURL]; ok { - return d - } - if d, ok := c.ecosystemDurations[ecosystem]; ok { - return d - } - return c.defaultDuration -} - -// 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 { - d := c.For(ecosystem, packagePURL) - if d == 0 { - return true - } - if publishedAt.IsZero() { - return true - } - return time.Since(publishedAt) >= d -} - -// Enabled returns true if any cooldown is configured. -func (c *Config) Enabled() bool { - c.parse() - if c.defaultDuration > 0 { - return true - } - for _, d := range c.ecosystemDurations { - if d > 0 { - return true - } - } - for _, d := range c.packageDurations { - if d > 0 { - return true - } - } - return false -} - -// ParseDuration parses a duration string supporting days (e.g., "3d"), -// in addition to Go's standard time.ParseDuration formats ("48h", "30m"). -// "0" means disabled (returns 0). -func ParseDuration(s string) (time.Duration, error) { - s = strings.TrimSpace(s) - if s == "" || s == "0" { - return 0, nil - } - - // Handle day suffix - if numStr, ok := strings.CutSuffix(s, "d"); ok { - days, err := strconv.ParseFloat(numStr, 64) - if err != nil { - return 0, fmt.Errorf("invalid duration %q: %w", s, err) - } - return time.Duration(days * float64(hoursPerDay*time.Hour)), nil - } - - d, err := time.ParseDuration(s) - if err != nil { - return 0, fmt.Errorf("invalid duration %q: %w", s, err) - } - return d, nil -} diff --git a/internal/cooldown/cooldown_test.go b/internal/cooldown/cooldown_test.go deleted file mode 100644 index c366077..0000000 --- a/internal/cooldown/cooldown_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package cooldown - -import ( - "testing" - "time" -) - -func TestParseDuration(t *testing.T) { - tests := []struct { - input string - want time.Duration - wantErr bool - }{ - {"", 0, false}, - {"0", 0, false}, - {"3d", 3 * 24 * time.Hour, false}, - {"7d", 7 * 24 * time.Hour, false}, - {"14d", 14 * 24 * time.Hour, false}, - {"1.5d", 36 * time.Hour, false}, - {"48h", 48 * time.Hour, false}, - {"30m", 30 * time.Minute, false}, - {"1h30m", 90 * time.Minute, false}, - {"invalid", 0, true}, - {"d", 0, true}, - {"xd", 0, true}, - } - - for _, tt := range tests { - got, err := ParseDuration(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("ParseDuration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) - continue - } - if got != tt.want { - t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, got, tt.want) - } - } -} - -func TestConfigFor(t *testing.T) { - c := &Config{ - Default: "3d", - Ecosystems: map[string]string{ - "npm": "7d", - "cargo": "0", - }, - Packages: map[string]string{ - "pkg:npm/lodash": "0", - "pkg:npm/@babel/core": "14d", - }, - } - - tests := []struct { - ecosystem string - packagePURL string - want time.Duration - }{ - // Package override takes priority - {"npm", "pkg:npm/lodash", 0}, - {"npm", "pkg:npm/@babel/core", 14 * 24 * time.Hour}, - // Ecosystem override - {"npm", "pkg:npm/express", 7 * 24 * time.Hour}, - {"cargo", "pkg:cargo/serde", 0}, - // Global default - {"pypi", "pkg:pypi/requests", 3 * 24 * time.Hour}, - {"pub", "pkg:pub/flutter", 3 * 24 * time.Hour}, - } - - for _, tt := range tests { - got := c.For(tt.ecosystem, tt.packagePURL) - if got != tt.want { - t.Errorf("For(%q, %q) = %v, want %v", tt.ecosystem, tt.packagePURL, got, tt.want) - } - } -} - -func TestConfigIsAllowed(t *testing.T) { - c := &Config{ - Default: "3d", - Packages: map[string]string{ - "pkg:npm/lodash": "0", - }, - } - - now := time.Now() - - tests := []struct { - name string - ecosystem string - packagePURL string - publishedAt time.Time - want bool - }{ - {"old enough", "npm", "pkg:npm/express", now.Add(-4 * 24 * time.Hour), true}, - {"too recent", "npm", "pkg:npm/express", now.Add(-1 * 24 * time.Hour), false}, - {"exactly at boundary", "npm", "pkg:npm/express", now.Add(-3 * 24 * time.Hour), true}, - {"exempt package", "npm", "pkg:npm/lodash", now.Add(-1 * time.Minute), true}, - {"zero time", "npm", "pkg:npm/express", time.Time{}, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := c.IsAllowed(tt.ecosystem, tt.packagePURL, tt.publishedAt) - if got != tt.want { - t.Errorf("IsAllowed(%q, %q, %v) = %v, want %v", - tt.ecosystem, tt.packagePURL, tt.publishedAt, got, tt.want) - } - }) - } -} - -func TestConfigEnabled(t *testing.T) { - tests := []struct { - name string - cfg Config - want bool - }{ - {"empty config", Config{}, false}, - {"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}, - {"all zero", Config{Default: "0", Ecosystems: map[string]string{"npm": "0"}}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.cfg.Enabled() - if got != tt.want { - t.Errorf("Enabled() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/handler/cargo_test.go b/internal/handler/cargo_test.go index 5ce81b6..10d3faf 100644 --- a/internal/handler/cargo_test.go +++ b/internal/handler/cargo_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" ) func cargoTestProxy() *Proxy { diff --git a/internal/handler/composer_test.go b/internal/handler/composer_test.go index 94ff8cb..e4d79af 100644 --- a/internal/handler/composer_test.go +++ b/internal/handler/composer_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" ) func TestComposerRewriteMetadata(t *testing.T) { diff --git a/internal/handler/conda_test.go b/internal/handler/conda_test.go index 24b0236..1b57039 100644 --- a/internal/handler/conda_test.go +++ b/internal/handler/conda_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" ) func TestCondaParseFilename(t *testing.T) { diff --git a/internal/handler/gem_test.go b/internal/handler/gem_test.go index 6dce324..7d90946 100644 --- a/internal/handler/gem_test.go +++ b/internal/handler/gem_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" ) func TestGemParseFilename(t *testing.T) { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 78f17cb..0ad0776 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -15,7 +15,7 @@ import ( "strings" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/metrics" "github.com/git-pkgs/proxy/internal/storage" diff --git a/internal/handler/hex_test.go b/internal/handler/hex_test.go index 19d34b4..b02540a 100644 --- a/internal/handler/hex_test.go +++ b/internal/handler/hex_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" "google.golang.org/protobuf/encoding/protowire" ) diff --git a/internal/handler/npm_test.go b/internal/handler/npm_test.go index 7db3539..bc1edde 100644 --- a/internal/handler/npm_test.go +++ b/internal/handler/npm_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" ) const testVersion100 = "1.0.0" diff --git a/internal/handler/nuget_test.go b/internal/handler/nuget_test.go index 68c9d22..b2164e5 100644 --- a/internal/handler/nuget_test.go +++ b/internal/handler/nuget_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" ) func nugetTestProxy() *Proxy { diff --git a/internal/handler/pub_test.go b/internal/handler/pub_test.go index 2788714..8a4c098 100644 --- a/internal/handler/pub_test.go +++ b/internal/handler/pub_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" ) func TestPubRewriteMetadata(t *testing.T) { diff --git a/internal/handler/pypi_test.go b/internal/handler/pypi_test.go index 9e2ade0..2b58960 100644 --- a/internal/handler/pypi_test.go +++ b/internal/handler/pypi_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" "github.com/git-pkgs/registries/fetch" ) diff --git a/internal/server/server.go b/internal/server/server.go index a0983e5..cd57ae3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -49,7 +49,7 @@ import ( swaggerdoc "github.com/git-pkgs/proxy/docs/swagger" "github.com/git-pkgs/proxy/internal/config" - "github.com/git-pkgs/proxy/internal/cooldown" + "github.com/git-pkgs/cooldown" "github.com/git-pkgs/proxy/internal/database" "github.com/git-pkgs/proxy/internal/enrichment" "github.com/git-pkgs/proxy/internal/handler"