Skip to content

Commit 4858286

Browse files
authored
Merge pull request #17 from git-pkgs/cooldown-feature
Add version cooldown to filter recently published packages
2 parents befc449 + dd4595d commit 4858286

17 files changed

Lines changed: 1075 additions & 16 deletions

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22

33
A caching proxy for package registries. Speeds up package downloads by caching artifacts locally, reducing bandwidth usage and improving reliability.
44

5+
## Version Cooldown
6+
7+
Most supply chain attacks rely on speed: a malicious version gets published and consumed by automated pipelines within minutes, before anyone notices. The cooldown feature adds a quarantine period to newly published versions. When enabled, the proxy strips versions from metadata responses until they've aged past a configurable threshold.
8+
9+
```yaml
10+
cooldown:
11+
default: "3d" # hide versions published less than 3 days ago
12+
ecosystems:
13+
npm: "7d" # npm gets a longer window
14+
cargo: "0" # disable for cargo
15+
packages:
16+
"pkg:npm/lodash": "0" # exempt trusted packages
17+
```
18+
19+
A 3-day cooldown means that when `lodash` publishes version `4.18.0`, your builds keep using `4.17.21` until 3 days have passed. If the new release turns out to be compromised, you were never exposed.
20+
21+
Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default and carve out exceptions for packages where you need faster updates.
22+
23+
Currently works with npm, PyPI, pub.dev, and Composer, which all include publish timestamps in their metadata. See [docs/configuration.md](docs/configuration.md) for the full config reference.
24+
525
## Supported Registries
626

727
| Registry | Language/Platform | URL Resolution | Handler | Completed |
@@ -353,6 +373,10 @@ log:
353373
upstream:
354374
npm: "https://registry.npmjs.org"
355375
cargo: "https://index.crates.io"
376+
377+
# Optional: version cooldown (see above)
378+
cooldown:
379+
default: "3d"
356380
```
357381
358382
Run with config file:

config.example.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,21 @@ upstream:
9090
# type: header
9191
# header_name: "X-Auth-Token"
9292
# header_value: "${MAVEN_TOKEN}"
93+
94+
# Version cooldown configuration
95+
# Hides package versions published too recently, giving the community time
96+
# to spot malicious releases before they're pulled into projects.
97+
# Supported durations: "7d" (days), "48h" (hours), "30m" (minutes), "0" (disabled)
98+
cooldown:
99+
# Global default cooldown for all ecosystems
100+
# default: "3d"
101+
102+
# Per-ecosystem overrides
103+
# ecosystems:
104+
# npm: "7d"
105+
# cargo: "0"
106+
107+
# Per-package overrides (keyed by PURL)
108+
# packages:
109+
# "pkg:npm/lodash": "0"
110+
# "pkg:npm/@babel/core": "14d"

docs/configuration.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,33 @@ upstream:
184184
token: "${PRIVATE_TOKEN}"
185185
```
186186

187+
## Cooldown
188+
189+
The cooldown feature hides package versions published too recently, giving the community time to spot malicious releases before they reach your projects. When a version is within its cooldown period, it's stripped from metadata responses so package managers won't install it.
190+
191+
```yaml
192+
cooldown:
193+
default: "3d"
194+
ecosystems:
195+
npm: "7d"
196+
cargo: "0"
197+
packages:
198+
"pkg:npm/lodash": "0"
199+
"pkg:npm/@babel/core": "14d"
200+
```
201+
202+
| Config | Environment | Description |
203+
|--------|-------------|-------------|
204+
| `cooldown.default` | `PROXY_COOLDOWN_DEFAULT` | Global default cooldown |
205+
| `cooldown.ecosystems` | - | Per-ecosystem overrides |
206+
| `cooldown.packages` | - | Per-package overrides (keyed by PURL) |
207+
208+
Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to disable.
209+
210+
Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages.
211+
212+
Currently supported for npm, PyPI, pub.dev, and Composer. These ecosystems include publish timestamps in their metadata. Other ecosystems (Go, Cargo, RubyGems) would require extra API calls and are not yet supported.
213+
187214
## Docker
188215

189216
### SQLite with Local Storage

internal/config/config.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,22 @@ type Config struct {
8080

8181
// Upstream configures upstream registry URLs (optional overrides).
8282
Upstream UpstreamConfig `json:"upstream" yaml:"upstream"`
83+
84+
// Cooldown configures version age filtering to mitigate supply chain attacks.
85+
Cooldown CooldownConfig `json:"cooldown" yaml:"cooldown"`
86+
}
87+
88+
// CooldownConfig configures version cooldown periods.
89+
// Versions published more recently than the cooldown are hidden from metadata responses.
90+
type CooldownConfig struct {
91+
// Default is the global default cooldown (e.g., "3d", "48h", "0" to disable).
92+
Default string `json:"default" yaml:"default"`
93+
94+
// Ecosystems overrides the default for specific ecosystems.
95+
Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"`
96+
97+
// Packages overrides the cooldown for specific packages (keyed by PURL).
98+
Packages map[string]string `json:"packages" yaml:"packages"`
8399
}
84100

85101
// StorageConfig configures artifact storage.
@@ -286,6 +302,9 @@ func (c *Config) LoadFromEnv() {
286302
if v := os.Getenv("PROXY_LOG_FORMAT"); v != "" {
287303
c.Log.Format = v
288304
}
305+
if v := os.Getenv("PROXY_COOLDOWN_DEFAULT"); v != "" {
306+
c.Cooldown.Default = v
307+
}
289308
}
290309

291310
// Validate checks the configuration for errors.

internal/config/config_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,62 @@ func TestLoadFromEnv(t *testing.T) {
233233
}
234234
}
235235

236+
func TestLoadCooldownConfig(t *testing.T) {
237+
dir := t.TempDir()
238+
path := filepath.Join(dir, "config.yaml")
239+
240+
content := `
241+
listen: ":8080"
242+
base_url: "http://localhost:8080"
243+
storage:
244+
path: "/data/cache"
245+
database:
246+
path: "/data/proxy.db"
247+
cooldown:
248+
default: "3d"
249+
ecosystems:
250+
npm: "7d"
251+
cargo: "0"
252+
packages:
253+
"pkg:npm/lodash": "0"
254+
"pkg:npm/@babel/core": "14d"
255+
`
256+
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
257+
t.Fatalf("writing config file: %v", err)
258+
}
259+
260+
cfg, err := Load(path)
261+
if err != nil {
262+
t.Fatalf("Load failed: %v", err)
263+
}
264+
265+
if cfg.Cooldown.Default != "3d" {
266+
t.Errorf("Cooldown.Default = %q, want %q", cfg.Cooldown.Default, "3d")
267+
}
268+
if cfg.Cooldown.Ecosystems["npm"] != "7d" {
269+
t.Errorf("Cooldown.Ecosystems[npm] = %q, want %q", cfg.Cooldown.Ecosystems["npm"], "7d")
270+
}
271+
if cfg.Cooldown.Ecosystems["cargo"] != "0" {
272+
t.Errorf("Cooldown.Ecosystems[cargo] = %q, want %q", cfg.Cooldown.Ecosystems["cargo"], "0")
273+
}
274+
if cfg.Cooldown.Packages["pkg:npm/lodash"] != "0" {
275+
t.Errorf("Cooldown.Packages[lodash] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/lodash"], "0")
276+
}
277+
if cfg.Cooldown.Packages["pkg:npm/@babel/core"] != "14d" {
278+
t.Errorf("Cooldown.Packages[@babel/core] = %q, want %q", cfg.Cooldown.Packages["pkg:npm/@babel/core"], "14d")
279+
}
280+
}
281+
282+
func TestLoadCooldownFromEnv(t *testing.T) {
283+
cfg := Default()
284+
t.Setenv("PROXY_COOLDOWN_DEFAULT", "5d")
285+
cfg.LoadFromEnv()
286+
287+
if cfg.Cooldown.Default != "5d" {
288+
t.Errorf("Cooldown.Default = %q, want %q", cfg.Cooldown.Default, "5d")
289+
}
290+
}
291+
236292
func TestLoadFileNotFound(t *testing.T) {
237293
_, err := Load("/nonexistent/config.yaml")
238294
if err == nil {

internal/cooldown/cooldown.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package cooldown
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
"time"
8+
)
9+
10+
// Config holds cooldown settings for version filtering.
11+
// Cooldown hides package versions published too recently, giving the community
12+
// time to spot malicious releases before they're pulled into projects.
13+
type Config struct {
14+
// Default is the global default cooldown duration (e.g., "3d", "48h").
15+
Default string `json:"default" yaml:"default"`
16+
17+
// Ecosystems overrides the default for specific ecosystems.
18+
// Keys are ecosystem names (e.g., "npm", "pypi").
19+
Ecosystems map[string]string `json:"ecosystems" yaml:"ecosystems"`
20+
21+
// Packages overrides the cooldown for specific packages.
22+
// Keys are PURLs (e.g., "pkg:npm/lodash", "pkg:npm/@babel/core").
23+
Packages map[string]string `json:"packages" yaml:"packages"`
24+
25+
defaultDuration time.Duration
26+
ecosystemDurations map[string]time.Duration
27+
packageDurations map[string]time.Duration
28+
parsed bool
29+
}
30+
31+
// parse resolves all string durations into time.Duration values.
32+
// Called lazily on first use.
33+
func (c *Config) parse() {
34+
if c.parsed {
35+
return
36+
}
37+
c.parsed = true
38+
39+
c.defaultDuration, _ = ParseDuration(c.Default)
40+
41+
c.ecosystemDurations = make(map[string]time.Duration, len(c.Ecosystems))
42+
for k, v := range c.Ecosystems {
43+
d, _ := ParseDuration(v)
44+
c.ecosystemDurations[k] = d
45+
}
46+
47+
c.packageDurations = make(map[string]time.Duration, len(c.Packages))
48+
for k, v := range c.Packages {
49+
d, _ := ParseDuration(v)
50+
c.packageDurations[k] = d
51+
}
52+
}
53+
54+
// For returns the effective cooldown duration for a given ecosystem and package PURL.
55+
// Resolution order: package override > ecosystem override > global default.
56+
func (c *Config) For(ecosystem, packagePURL string) time.Duration {
57+
c.parse()
58+
59+
if d, ok := c.packageDurations[packagePURL]; ok {
60+
return d
61+
}
62+
if d, ok := c.ecosystemDurations[ecosystem]; ok {
63+
return d
64+
}
65+
return c.defaultDuration
66+
}
67+
68+
// IsAllowed returns true if a version with the given publish time has passed
69+
// the cooldown period for this ecosystem/package.
70+
func (c *Config) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool {
71+
d := c.For(ecosystem, packagePURL)
72+
if d == 0 {
73+
return true
74+
}
75+
if publishedAt.IsZero() {
76+
return true
77+
}
78+
return time.Since(publishedAt) >= d
79+
}
80+
81+
// Enabled returns true if any cooldown is configured.
82+
func (c *Config) Enabled() bool {
83+
c.parse()
84+
if c.defaultDuration > 0 {
85+
return true
86+
}
87+
for _, d := range c.ecosystemDurations {
88+
if d > 0 {
89+
return true
90+
}
91+
}
92+
for _, d := range c.packageDurations {
93+
if d > 0 {
94+
return true
95+
}
96+
}
97+
return false
98+
}
99+
100+
// ParseDuration parses a duration string supporting days (e.g., "3d"),
101+
// in addition to Go's standard time.ParseDuration formats ("48h", "30m").
102+
// "0" means disabled (returns 0).
103+
func ParseDuration(s string) (time.Duration, error) {
104+
s = strings.TrimSpace(s)
105+
if s == "" || s == "0" {
106+
return 0, nil
107+
}
108+
109+
// Handle day suffix
110+
if numStr, ok := strings.CutSuffix(s, "d"); ok {
111+
days, err := strconv.ParseFloat(numStr, 64)
112+
if err != nil {
113+
return 0, fmt.Errorf("invalid duration %q: %w", s, err)
114+
}
115+
return time.Duration(days * float64(24*time.Hour)), nil
116+
}
117+
118+
d, err := time.ParseDuration(s)
119+
if err != nil {
120+
return 0, fmt.Errorf("invalid duration %q: %w", s, err)
121+
}
122+
return d, nil
123+
}

0 commit comments

Comments
 (0)