feat(cooldown): respect trusted_packages in dependency cooldown (#342)

* feat(cooldown): respect trusted_packages in dependency cooldown

Trusted packages are now treated as a superset waiver that bypasses every
PMG control (malware analysis, cooldown, and any future controls). A
globally trusted package is automatically exempt from the cooldown window
and no longer needs a duplicate entry in dependency_cooldown.skip.

The skip list remains the narrower, cooldown-only waiver for packages
that must bypass the cooldown wait but still be malware-scanned.

* refactor(cooldown): tag skip reason and audit-log skipped packages

Address review feedback on #342:

- Restore cooldownSkip to a pure single-list function (SRP); the merge
  into trusted_packages now happens in a separate mergeCooldownSkip step,
  driven by the exported CooldownSkip wrapper.
- Extend CooldownSkipInfo with a CooldownSkipReason (TrustedPackage /
  CooldownSkipList) on both SkipAll and per-version entries, so callers
  can tell apart the broad waiver from the cooldown-only one. When both
  lists match the same package, trusted_packages wins.
- Add audit.LogCooldownSkipped and emit it from the npm and PyPI
  interceptors on the SkipAll path, alongside the existing info log,
  carrying the source list as the reason.

* refactor(cooldown): inline list merge, audit per-version exemptions

Address further review feedback:

- Drop the separate mergeCooldownSkip helper; cooldownSkip now writes
  into a shared *CooldownSkipInfo and is called twice from CooldownSkip
  (cooldown skip list first, trusted_packages on top so trusted entries
  override the reason on overlap).
- Audit log every exemption, not just SkipAll: a new auditCooldownSkip
  helper in proxy/interceptors/cooldown.go emits one event per match
  (package-wide or per-version), each tagged with its source list.
  LogCooldownSkipped gains a version argument for the per-version case.
- Cover the trusted_packages reason path in TestCooldownSkip.

* fix(cooldown): avoid double-auditing trusted package exemptions

auditCooldownSkip now only emits EventTypeCooldownSkipped for entries
that came from dependency_cooldown.skip. Trusted-package exemptions
already get an EventTypeInstallTrustedAllowed event at tarball-download
time (proxy/interceptors/base_registry.go), so emitting a cooldown event
for them too would double-count the same waiver.

* emit trusted and cooldown skip events to cloud

* fix tests

* refactor(cooldown): return value from collectCooldownSkip, short-circuit on trusted SkipAll

Address PR review feedback:
- Rename cooldownSkip to collectCooldownSkip and return CooldownSkipInfo
  instead of mutating an input pointer.
- Add mergeCooldownSkip to combine per-list results with trusted_packages
  taking precedence on overlap.
- CooldownSkip now consults trusted_packages first and returns immediately
  on a package-wide trusted exemption (DC skip list cannot add anything).
- Extend tests to cover disjoint pinned entries across both lists and the
  case where DC version-less subsumes a trusted pinned entry.

* fix(audit): address cooldown review feedback

* fix(cooldown): audit cooldown skips at download time with concrete version

Backend rejects PackageVersion messages without a version, and audit logs
should reflect the runtime fact (a specific version was skipped) rather
than the config rule. Move the audit emission from metadata-request
handling to download-request handling, where the concrete version is
known, and require version in LogCooldownSkipped.

* chore(audit): drop dead scope assignment in LogCooldownSkipped

* refactor(cooldown): move skip-list logic into cooldown handlers

Registry interceptors no longer compute CooldownSkip or branch on SkipAll;
they just call HandleMetadataRequest. The npm and pypi cooldown handlers
own the skip lookup, the package-wide exemption short-circuit, and (for
pypi) the canonical-name denormalization. Also align LogCooldownSkipped
with other LogXxx signatures by taking *packagev1.PackageVersion.

* fix: Simplify audit logging for dependency cooldown skip

* refactor: Simplify cooldown handling and maintain separation of concepts for trusted and DC skip packages

* fix: Code review fixes

* fix: Emit cooldown skipped audit event ONLY when an in-window version is skipped

---------

Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
This commit is contained in:
Sahil Bansal
2026-06-21 18:22:15 +05:30
committed by GitHub
co-authored by Abhisek Datta
parent c17b941ac3
commit 327c9c7068
27 changed files with 569 additions and 152 deletions
+30 -24
View File
@@ -71,6 +71,36 @@ func (b *baseRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*pro
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// fastAllow short-circuits the request when no security control should run for
// this concrete version: insecure-installation mode (analysis globally off) or a
// trusted package (waives every control). It returns (response, true) when it
// handled the request; (nil, false) otherwise. Insecure is checked first to
// preserve the previous ordering inside analyzePackage.
func (b *baseRegistryInterceptor) fastAllow(
ctx *proxy.RequestContext,
ecosystem packagev1.Ecosystem,
name, version string,
) (*proxy.InterceptorResponse, bool) {
pkgVersion := &packagev1.PackageVersion{
Package: &packagev1.Package{Ecosystem: ecosystem, Name: name},
Version: version,
}
if config.Get().InsecureInstallation {
log.Debugf("[%s] Skipping insecure installation", ctx.RequestID)
audit.LogInstallInsecureBypass(pkgVersion)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, true
}
if config.IsTrustedPackageRef(ecosystem, name, version) {
log.Debugf("[%s] Skipping trusted package: %s/%s@%s", ctx.RequestID, ecosystem.String(), name, version)
audit.LogInstallTrustedAllowed(pkgVersion)
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, true
}
return nil, false
}
// analyzePackage analyzes a package using the configured analyzer with caching
// This method is ecosystem-agnostic and can be used by any registry interceptor
func (b *baseRegistryInterceptor) analyzePackage(
@@ -79,7 +109,6 @@ func (b *baseRegistryInterceptor) analyzePackage(
packageName string,
packageVersion string,
) (*analyzer.PackageVersionAnalysisResult, error) {
// Check if package is trusted before analyzing
pkgVersion := &packagev1.PackageVersion{
Package: &packagev1.Package{
Ecosystem: ecosystem,
@@ -88,29 +117,6 @@ func (b *baseRegistryInterceptor) analyzePackage(
Version: packageVersion,
}
if cfg := config.Get(); cfg.InsecureInstallation {
log.Debugf("[%s] Skipping insecure installation", ctx.RequestID)
audit.LogInstallInsecureBypass(pkgVersion)
return &analyzer.PackageVersionAnalysisResult{
PackageVersion: pkgVersion,
Action: analyzer.ActionAllow,
}, nil
}
if config.IsTrustedPackage(pkgVersion) {
log.Debugf("[%s] Skipping trusted package: %s/%s@%s",
ctx.RequestID, ecosystem.String(), packageName, packageVersion)
audit.LogInstallTrustedAllowed(pkgVersion)
return &analyzer.PackageVersionAnalysisResult{
PackageVersion: pkgVersion,
Action: analyzer.ActionAllow,
}, nil
}
if cached, ok := b.cache.Get(ecosystem.String(), packageName, packageVersion); ok {
log.Debugf("[%s] Using cached analysis result for %s@%s", ctx.RequestID, packageName, packageVersion)
return cached, nil
+48
View File
@@ -8,10 +8,58 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/pmg/analyzer"
pmgconfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/proxy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setTrustedPackagesForTest(t *testing.T, pkgs []pmgconfig.TrustedPackage) {
t.Helper()
orig := pmgconfig.Get().Config.TrustedPackages
pmgconfig.Get().Config.TrustedPackages = pkgs
require.NoError(t, pmgconfig.PreprocessTrustedPackages(&pmgconfig.Get().Config), "setTrustedPackagesForTest: preprocess")
t.Cleanup(func() {
pmgconfig.Get().Config.TrustedPackages = orig
assert.NoError(t, pmgconfig.PreprocessTrustedPackages(&pmgconfig.Get().Config))
})
}
func TestFastAllow_TrustedReturnsAllow(t *testing.T) {
setTrustedPackagesForTest(t, []pmgconfig.TrustedPackage{{Purl: "pkg:npm/trusted-pkg"}})
b := &baseRegistryInterceptor{}
ctx := makeTestRequestContext("https://registry.npmjs.org/trusted-pkg/-/trusted-pkg-1.0.0.tgz")
resp, ok := b.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, "trusted-pkg", "1.0.0")
require.True(t, ok)
assert.Equal(t, proxy.ActionAllow, resp.Action)
}
func TestFastAllow_UntrustedReturnsFalse(t *testing.T) {
setTrustedPackagesForTest(t, nil)
b := &baseRegistryInterceptor{}
ctx := makeTestRequestContext("https://registry.npmjs.org/x/-/x-1.0.0.tgz")
resp, ok := b.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, "x", "1.0.0")
assert.False(t, ok)
assert.Nil(t, resp)
}
func TestFastAllow_InsecureReturnsAllow(t *testing.T) {
orig := pmgconfig.Get().InsecureInstallation
pmgconfig.Get().InsecureInstallation = true
t.Cleanup(func() { pmgconfig.Get().InsecureInstallation = orig })
b := &baseRegistryInterceptor{}
ctx := makeTestRequestContext("https://registry.npmjs.org/any-pkg/-/any-pkg-1.0.0.tgz")
resp, ok := b.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, "any-pkg", "1.0.0")
require.True(t, ok)
assert.Equal(t, proxy.ActionAllow, resp.Action)
}
func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
tests := []struct {
name string
+58
View File
@@ -6,9 +6,67 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/Masterminds/semver"
"github.com/safedep/dry/log"
pmgconfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/audit"
)
// cooldownExemptions describes the in-window versions that survive cooldown
// stripping and why. all is the full exempt set; skipListed is the subset
// attributable to dependency_cooldown.skip and is the only set that produces an
// audit event — trusted versions surface as install_trusted_allowed via the
// proxy fast-allow gate at download time.
type cooldownExemptions struct {
all map[string]bool
skipListed []string
}
// cooldownExemptVersions classifies the versions that must survive stripping
// even though they fall within the cooldown window: those trusted via
// trusted_packages or on the dependency_cooldown.skip list. Only in-window
// versions are examined, bounding the per-version trusted lookup to recent
// releases rather than the full (potentially large) version history. A version
// that is both trusted and skip-listed is attributed to trusted, mirroring the
// download-path precedence where the fast-allow gate wins.
func cooldownExemptVersions(ecosystem packagev1.Ecosystem, name string, skip pmgconfig.CooldownSkipInfo, dates map[string]time.Time, cooldownDays int) cooldownExemptions {
exempt := cooldownExemptions{all: make(map[string]bool)}
for v, publishDate := range dates {
if within, _, _ := cooldownIsWithinWindow(publishDate, cooldownDays); !within {
continue
}
switch {
case pmgconfig.IsTrustedPackageRef(ecosystem, name, v):
exempt.all[v] = true
case skip.SkipAll:
// A whole-package skip is one waiver, not a per-version exemption:
// keep the version but never emit per-version audit events.
// HandleMetadataRequest also short-circuits this case upstream.
exempt.all[v] = true
case skip.ExemptsVersion(v):
exempt.all[v] = true
exempt.skipListed = append(exempt.skipListed, v)
}
}
return exempt
}
// auditCooldownSkips emits one dependency_cooldown_skipped audit event per
// version that the skip list exempted from an active cooldown window. It is
// called from the metadata modifier, where publish dates are known, so the
// event only fires for versions a live cooldown would otherwise have stripped.
func auditCooldownSkips(requestID string, ecosystem packagev1.Ecosystem, name string, exempt cooldownExemptions) {
for _, version := range exempt.skipListed {
log.Infof("[%s] Cooldown: %s@%s exempt by %s", requestID, name, version, audit.CooldownSkipReason)
pv := &packagev1.PackageVersion{}
pv.SetPackage(&packagev1.Package{})
pv.GetPackage().SetName(name)
pv.GetPackage().SetEcosystem(ecosystem)
pv.SetVersion(version)
audit.LogCooldownSkipped(pv)
}
}
// cooldownIsWithinWindow reports whether a version published at publishDate is still
// within the cooldown window of cooldownDays. Returns withinCooldown, daysSincePublish,
// and daysRemaining.
+76
View File
@@ -4,9 +4,85 @@ import (
"testing"
"time"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
pmgconfig "github.com/safedep/pmg/config"
"github.com/stretchr/testify/assert"
)
func TestCooldownExemptVersions(t *testing.T) {
setTrustedPackagesForTest(t, []pmgconfig.TrustedPackage{
{Purl: "pkg:npm/pkg@2.0.0"},
{Purl: "pkg:npm/pkg@3.0.0"}, // both trusted and skip-listed below
})
now := time.Now()
day := 24 * time.Hour
dates := map[string]time.Time{
"1.0.0": now.Add(-1 * day), // in window, skip-listed -> audited
"2.0.0": now.Add(-1 * day), // in window, trusted -> exempt, not audited
"3.0.0": now.Add(-1 * day), // in window, trusted + skip-listed -> trusted wins
"4.0.0": now.Add(-100 * day), // skip-listed but out of window -> not exempt
"5.0.0": now.Add(-1 * day), // in window, neither -> stripped
}
skip := pmgconfig.CooldownSkipInfo{Versions: map[string]bool{
"1.0.0": true,
"3.0.0": true,
"4.0.0": true,
}}
exempt := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pkg", skip, dates, 5)
assert.ElementsMatch(t, []string{"1.0.0", "2.0.0", "3.0.0"}, keysOf(exempt.all))
// Only the in-window, skip-listed, non-trusted version is audited.
assert.Equal(t, []string{"1.0.0"}, exempt.skipListed)
}
// An out-of-window skip-listed version, or a zero-day cooldown, must not be
// reported as a cooldown bypass: nothing was actually within an active window.
func TestCooldownExemptVersions_NoActiveWindow(t *testing.T) {
setTrustedPackagesForTest(t, nil)
now := time.Now()
day := 24 * time.Hour
dates := map[string]time.Time{"1.0.0": now.Add(-100 * day)}
skip := pmgconfig.CooldownSkipInfo{Versions: map[string]bool{"1.0.0": true}}
oldVersion := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pkg", skip, dates, 5)
assert.Empty(t, oldVersion.skipListed)
assert.Empty(t, oldVersion.all)
freshDates := map[string]time.Time{"1.0.0": now.Add(-1 * day)}
zeroDay := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pkg", skip, freshDates, 0)
assert.Empty(t, zeroDay.skipListed)
assert.Empty(t, zeroDay.all)
}
// A whole-package skip (version-less entry) exempts every version but must not
// produce per-version audit events — it is a single package-level waiver.
func TestCooldownExemptVersions_SkipAll(t *testing.T) {
setTrustedPackagesForTest(t, nil)
now := time.Now()
day := 24 * time.Hour
dates := map[string]time.Time{
"1.0.0": now.Add(-1 * day),
"2.0.0": now.Add(-1 * day),
}
skip := pmgconfig.CooldownSkipInfo{SkipAll: true}
exempt := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, "pkg", skip, dates, 5)
assert.ElementsMatch(t, []string{"1.0.0", "2.0.0"}, keysOf(exempt.all))
assert.Empty(t, exempt.skipListed, "whole-package skip must not emit per-version events")
}
func keysOf(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
func TestCooldownIsWithinWindow(t *testing.T) {
now := time.Now()
day := 24 * time.Hour
+15 -3
View File
@@ -8,6 +8,7 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
pmgconfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/proxy"
)
@@ -32,8 +33,17 @@ func newNpmCooldownHandler(statsCollector *AnalysisStatsCollector) *npmCooldownH
// HandleMetadataRequest overrides the Accept header to force the registry to return
// a full packument (which includes publish dates in the "time" field), then registers
// a response modifier that strips versions within the cooldown window.
func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string, exemptVersions map[string]bool) (*proxy.InterceptorResponse, error) {
// a response modifier that strips versions within the cooldown window. Skip-list
// semantics are handled here so callers do not need to consult the config.
func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string) (*proxy.InterceptorResponse, error) {
skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_NPM, packageName)
if skip.SkipAll {
// Whole package is on the cooldown skip list: pass metadata through
// unmodified. The tarball download still hits analyzePackage, so malware
// analysis is preserved.
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName)
// Force full packument so the response always contains the "time" field.
@@ -62,7 +72,9 @@ func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, pa
log.Debugf("[%s] Cooldown: parsed %d publish dates for %s", ctx.RequestID, len(dates), packageName)
strippedBody, stripped, remaining := h.stripCooldownVersions(body, dates, cooldownDays, exemptVersions)
exempt := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, packageName, skip, dates, cooldownDays)
auditCooldownSkips(ctx.RequestID, packagev1.Ecosystem_ECOSYSTEM_NPM, packageName, exempt)
strippedBody, stripped, remaining := h.stripCooldownVersions(body, dates, cooldownDays, exempt.all)
if stripped > 0 {
log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)",
ctx.RequestID, stripped, packageName, cooldownDays, remaining)
+41 -9
View File
@@ -432,7 +432,7 @@ func TestNpmCooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
ctx.Headers.Set("If-None-Match", `"abc123"`)
ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT")
resp, err := handler.HandleMetadataRequest(ctx, "lodash", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "lodash", 5, "")
require.NoError(t, err)
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
@@ -454,7 +454,7 @@ func TestNpmCooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -489,7 +489,7 @@ func TestNpmCooldown_HandleMetadataRequest_NoVersionsInCooldown(t *testing.T) {
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -510,7 +510,7 @@ func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/newpkg")
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -542,7 +542,7 @@ func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_ReportsOldestVe
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/multipkg")
resp, err := handler.HandleMetadataRequest(ctx, "multipkg", 100, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "multipkg", 100, "")
require.NoError(t, err)
_, _, _, err = resp.ResponseModifier(200, http.Header{}, body)
@@ -559,7 +559,7 @@ func TestNpmCooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T)
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/badpkg")
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -581,7 +581,7 @@ func TestNpmCooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats(
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -612,7 +612,7 @@ func TestNpmCooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -636,7 +636,7 @@ func TestNpmCooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBlock
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -678,6 +678,38 @@ func TestNpmCooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) {
assert.Equal(t, "application/vnd.npm.install-v1+json", ctx.Headers.Get("Accept"))
}
func TestNpmCooldown_HandleMetadataRequest_PreservesTrustedVersion(t *testing.T) {
setTrustedPackagesForTest(t, []config.TrustedPackage{{Purl: "pkg:npm/testpkg@2.0.0"}})
now := time.Now()
versions := map[string]time.Time{
"1.0.0": now.Add(-30 * 24 * time.Hour), // old — eligible
"2.0.0": now.Add(-1 * 24 * time.Hour), // fresh, trusted — must be preserved
"2.1.0": now.Add(-1 * 24 * time.Hour), // fresh, untrusted — must be stripped
}
distTags := map[string]string{"latest": "2.1.0"}
body := buildTestPackument(versions, distTags)
collector := NewAnalysisStatsCollector()
handler := newNpmCooldownHandler(collector)
ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg")
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
_, _, newBody, err := resp.ResponseModifier(200, http.Header{}, body)
require.NoError(t, err)
var meta struct {
Versions map[string]json.RawMessage `json:"versions"`
}
require.NoError(t, json.Unmarshal(newBody, &meta))
assert.Contains(t, meta.Versions, "2.0.0", "trusted fresh version must be preserved")
assert.NotContains(t, meta.Versions, "2.1.0", "untrusted fresh version must be stripped")
assert.Contains(t, meta.Versions, "1.0.0", "old version must be preserved")
}
func TestNpmCooldown_TarballRequestBypassesCooldown(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
+6 -9
View File
@@ -113,23 +113,20 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox
if !pkgInfo.IsFileDownload() {
if depCooldownConfig.Enabled {
skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName())
if skip.SkipAll {
// Whole package is on the cooldown skip list: let the metadata pass
// through unmodified. The tarball download still hits analyzePackage,
// so malware analysis is preserved.
log.Infof("[%s] Cooldown: package %s is on the skip list (dependency_cooldown.skip); malware analysis still applies", ctx.RequestID, pkgInfo.GetName())
if pmgconfig.IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName()) {
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// Version-pinned skip entries (if any) are preserved during stripping.
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()], skip.Versions)
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()])
}
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
if resp, ok := i.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, pkgInfo.GetName(), pkgInfo.GetVersion()); ok {
return resp, nil
}
result, err := i.analyzePackage(
ctx,
packagev1.Ecosystem_ECOSYSTEM_NPM,
+18 -3
View File
@@ -9,6 +9,7 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
pmgconfig "github.com/safedep/pmg/config"
"github.com/safedep/pmg/proxy"
)
@@ -28,8 +29,20 @@ func newPypiCooldownHandler(statsCollector *AnalysisStatsCollector) *pypiCooldow
// HandleMetadataRequest overrides the Accept header to force a PEP 691 JSON response,
// then registers a response modifier that strips files for versions within the cooldown window.
// If the client does not support PEP 691 (pip < 22.3), cooldown is skipped to avoid
// returning a content type the client cannot parse.
func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string, exemptVersions map[string]bool) (*proxy.InterceptorResponse, error) {
// returning a content type the client cannot parse. Skip-list semantics are
// handled here so callers do not need to consult the config.
func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string) (*proxy.InterceptorResponse, error) {
// Skip-list entries use the canonical (lowercase, _/. → -) name to match
// the same form used for pinned-version lookups.
canonical := denormalizePyPIPackageName(packageName)
skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_PYPI, canonical)
if skip.SkipAll {
// Whole package is on the cooldown skip list: pass metadata through
// unmodified. The file download still hits analyzePackage, so malware
// analysis is preserved.
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName)
originalAccept := ctx.Headers.Get("Accept")
@@ -62,7 +75,9 @@ func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, p
log.Debugf("[%s] Cooldown: parsed %d versions for %s", ctx.RequestID, len(dates), packageName)
strippedBody, stripped, remaining := h.stripCooldownFiles(body, dates, cooldownDays, exemptVersions)
exempt := cooldownExemptVersions(packagev1.Ecosystem_ECOSYSTEM_PYPI, canonical, skip, dates, cooldownDays)
auditCooldownSkips(ctx.RequestID, packagev1.Ecosystem_ECOSYSTEM_PYPI, canonical, exempt)
strippedBody, stripped, remaining := h.stripCooldownFiles(body, dates, cooldownDays, exempt.all)
if stripped > 0 {
log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)",
ctx.RequestID, stripped, packageName, cooldownDays, remaining)
+53 -10
View File
@@ -357,7 +357,7 @@ func TestPyPICooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) {
ctx.Headers.Set("If-None-Match", `"abc123"`)
ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT")
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
require.NoError(t, err)
assert.Equal(t, proxy.ActionModifyResponse, resp.Action)
assert.Equal(t, "application/vnd.pypi.simple.v1+json", ctx.Headers.Get("Accept"))
@@ -371,7 +371,7 @@ func TestPyPICooldown_HandleMetadataRequest_ClientWithoutPEP691_SkipsCooldown(t
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
ctx.Headers.Set("Accept", "text/html")
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
require.NoError(t, err)
assert.Equal(t, proxy.ActionAllow, resp.Action)
assert.Nil(t, resp.ResponseModifier)
@@ -383,7 +383,7 @@ func TestPyPICooldown_HandleMetadataRequest_NonJSONResponse_FailOpen(t *testing.
ctx := makeTestRequestContext("https://pypi.org/simple/requests/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -410,7 +410,7 @@ func TestPyPICooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) {
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -448,7 +448,7 @@ func TestPyPICooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t
ctx := makeTestRequestContext("https://pypi.org/simple/newpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -482,7 +482,7 @@ func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -499,7 +499,7 @@ func TestPyPICooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T)
ctx := makeTestRequestContext("https://pypi.org/simple/badpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -526,7 +526,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -561,7 +561,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -589,7 +589,7 @@ func TestPyPICooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBloc
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "", nil)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
@@ -663,6 +663,49 @@ func TestPyPICooldown_JSONAPIRequest_NotIntercepted(t *testing.T) {
assert.Equal(t, "application/json", ctx.Headers.Get("Accept"))
}
func TestPyPICooldown_HandleMetadataRequest_PreservesTrustedVersion(t *testing.T) {
// testpkg@2.0.0 is trusted; 2.1.0 is not. Both are fresh (in cooldown).
setTrustedPackagesForTest(t, []config.TrustedPackage{{Purl: "pkg:pypi/testpkg@2.0.0"}})
now := time.Now()
day := 24 * time.Hour
versions := map[string]time.Time{
"1.0.0": now.Add(-30 * day), // old — eligible
"2.0.0": now.Add(-1 * day), // fresh, trusted — must be preserved
"2.1.0": now.Add(-1 * day), // fresh, untrusted — must be stripped
}
body := buildTestPEP691Response(versions)
handler := newPypiCooldownHandler(NewAnalysisStatsCollector())
ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/")
ctx.Headers.Set("Accept", pypiSimpleAPIContentType)
resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "")
require.NoError(t, err)
require.NotNil(t, resp.ResponseModifier)
headers := http.Header{}
headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json")
_, _, retBody, err := resp.ResponseModifier(200, headers, body)
require.NoError(t, err)
var result struct {
Files []struct {
Filename string `json:"filename"`
} `json:"files"`
}
require.NoError(t, json.Unmarshal(retBody, &result))
filenames := make([]string, 0, len(result.Files))
for _, f := range result.Files {
filenames = append(filenames, f.Filename)
}
assert.Contains(t, filenames, "testpkg-2.0.0.tar.gz", "trusted fresh version must be preserved")
assert.NotContains(t, filenames, "testpkg-2.1.0.tar.gz", "untrusted fresh version must be stripped")
assert.Contains(t, filenames, "testpkg-1.0.0.tar.gz", "old version must be preserved")
}
func TestPyPICooldown_FileDownloadBypassesCooldown(t *testing.T) {
setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5})
+10 -12
View File
@@ -126,20 +126,10 @@ func (i *PypiRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*pro
// for version resolution. JSON API requests (/pypi/{pkg}/json) are allowed through;
// they have a different response structure and pip does not use them for installs.
if depCooldownConfig.Enabled && strings.HasPrefix(ctx.URL.Path, "/simple/") {
// Match the skip list against the normalized name (lowercase, _/. → -),
// the same canonical form used for pinned-version lookups, so a PURL
// like pkg:pypi/My_Pkg reliably matches the resolved request name.
skip := pmgconfig.CooldownSkip(packagev1.Ecosystem_ECOSYSTEM_PYPI, denormalizePyPIPackageName(pkgInfo.GetName()))
if skip.SkipAll {
// Whole package is on the cooldown skip list: pass metadata through
// unmodified. The tarball download still hits analyzePackage, so
// malware analysis is preserved.
log.Infof("[%s] Cooldown: package %s is on the skip list (dependency_cooldown.skip); malware analysis still applies", ctx.RequestID, pkgInfo.GetName())
if pmgconfig.IsTrustedPackageAllVersions(packagev1.Ecosystem_ECOSYSTEM_PYPI, denormalizePyPIPackageName(pkgInfo.GetName())) {
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// Version-pinned skip entries (if any) are preserved during stripping.
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()], skip.Versions)
return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days, i.execContext.PinnedVersions[pkgInfo.GetName()])
}
log.Debugf("[%s] Skipping analysis for metadata request: %s", ctx.RequestID, pkgInfo.GetName())
@@ -153,6 +143,14 @@ func (i *PypiRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*pro
return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil
}
// Canonical name is used for identity checks (trusted, cooldown); raw name
// is kept for analyzePackage so malware analysis sees the original form.
canonicalName := denormalizePyPIPackageName(pkgInfo.GetName())
if resp, ok := i.fastAllow(ctx, packagev1.Ecosystem_ECOSYSTEM_PYPI, canonicalName, pkgInfo.GetVersion()); ok {
return resp, nil
}
// Get file type for logging if available
fileType := ""
if pypiInfo, ok := pkgInfo.(*pypiPackageInfo); ok {