diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 9f597ec..bca5f02 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -152,8 +152,19 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema Block: ui.BlockNoExit, } + // Extract pinned versions from install targets so cooldown handlers can + // report when a user's explicitly requested version was blocked. + pinnedVersions := make(map[string]string) + for _, target := range parsedCmd.InstallTargets { + if target.IsExplicitVersion { + pinnedVersions[target.PackageVersion.GetPackage().GetName()] = target.PackageVersion.GetVersion() + } + } + // Create ecosystem-specific interceptor using factory - factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, statsCollector, confirmationChan) + factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, statsCollector, confirmationChan, interceptors.InterceptorContext{ + PinnedVersions: pinnedVersions, + }) interceptor, err := factory.CreateInterceptor(ecosystem) if err != nil { return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err) diff --git a/packagemanager/npm.go b/packagemanager/npm.go index b69e749..cdb7004 100644 --- a/packagemanager/npm.go +++ b/packagemanager/npm.go @@ -203,6 +203,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error } installTargets = append(installTargets, &PackageInstallTarget{ + IsExplicitVersion: version != "", PackageVersion: &packagev1.PackageVersion{ Package: &packagev1.Package{ Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, diff --git a/packagemanager/npm_executor.go b/packagemanager/npm_executor.go index 1f7871b..8ff8678 100644 --- a/packagemanager/npm_executor.go +++ b/packagemanager/npm_executor.go @@ -106,6 +106,7 @@ func (n *npmPackageExecutor) ParseCommand(args []string) (*ParsedCommand, error) } installTarget := &PackageInstallTarget{ + IsExplicitVersion: version != "", PackageVersion: &packagev1.PackageVersion{ Package: &packagev1.Package{ Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, diff --git a/packagemanager/packagemanager.go b/packagemanager/packagemanager.go index 2f30b5d..feaeade 100644 --- a/packagemanager/packagemanager.go +++ b/packagemanager/packagemanager.go @@ -20,6 +20,10 @@ type PackageInstallTarget struct { // Example: "django[mysql,redis]" has Extras as ["mysql", "redis"] // Currently only specific to Python packages Extras []string + + // IsExplicitVersion indicates the user provided an explicit version constraint + // (e.g. ==1.2.3) as opposed to the version being auto-resolved by the resolver. + IsExplicitVersion bool } func (pit *PackageInstallTarget) HasVersion() bool { diff --git a/packagemanager/pypi.go b/packagemanager/pypi.go index 07046b2..03428f7 100644 --- a/packagemanager/pypi.go +++ b/packagemanager/pypi.go @@ -188,6 +188,8 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { return nil, ErrFailedToParsePackage.Wrap(err) } + isExplicit := version != "" + version, err = pypiGetMatchingVersion(packageName, version) if err != nil { return nil, ErrFailedToResolveVersion.Wrap(err) @@ -201,7 +203,8 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { }, Version: version, }, - Extras: extras, + Extras: extras, + IsExplicitVersion: isExplicit, }) } @@ -304,6 +307,8 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { return nil, ErrFailedToParsePackage.Wrap(err) } + isExplicit := version != "" + version, err = pypiGetMatchingVersion(packageName, version) if err != nil { return nil, ErrFailedToResolveVersion.Wrap(err) @@ -317,7 +322,8 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { }, Version: version, }, - Extras: extras, + Extras: extras, + IsExplicitVersion: isExplicit, }) } @@ -400,6 +406,8 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error return nil, ErrFailedToParsePackage.Wrap(err) } + isExplicit := version != "" + version, err = pypiGetMatchingVersion(packageName, version) if err != nil { return nil, ErrFailedToResolveVersion.Wrap(err) @@ -413,7 +421,8 @@ func (p *poetryCommandParser) ParseCommand(args []string) (*ParsedCommand, error }, Version: version, }, - Extras: extras, + Extras: extras, + IsExplicitVersion: isExplicit, }) } diff --git a/proxy/interceptors/base_registry.go b/proxy/interceptors/base_registry.go index fd80280..cf16f65 100644 --- a/proxy/interceptors/base_registry.go +++ b/proxy/interceptors/base_registry.go @@ -25,6 +25,7 @@ type baseRegistryInterceptor struct { statsCollector *AnalysisStatsCollector confirmationChan chan *ConfirmationRequest circuitBreaker *gobreaker.CircuitBreaker[*analyzer.PackageVersionAnalysisResult] + execContext InterceptorContext } func newAnalyzerCircuitBreaker(name string) *gobreaker.CircuitBreaker[*analyzer.PackageVersionAnalysisResult] { diff --git a/proxy/interceptors/cooldown.go b/proxy/interceptors/cooldown.go index a087429..0a832fb 100644 --- a/proxy/interceptors/cooldown.go +++ b/proxy/interceptors/cooldown.go @@ -31,6 +31,29 @@ func cooldownOldestVersion(dates map[string]time.Time) (string, time.Time) { return oldest, oldestTime } +// recordCooldownStats records a cooldown block event. When all versions are blocked +// (remaining == 0), it reports the oldest version (closest to exiting cooldown). +// Otherwise, if a pinned version was stripped, it reports that specific version. +func recordCooldownStats(statsCollector *AnalysisStatsCollector, packageName string, pinnedVersion string, dates map[string]time.Time, remaining int, cooldownDays int) { + if statsCollector == nil { + return + } + + if remaining == 0 { + oldestVer, oldestDate := cooldownOldestVersion(dates) + if oldestVer != "" { + _, daysAgo, daysLeft := cooldownIsWithinWindow(oldestDate, cooldownDays) + statsCollector.RecordCooldownBlocked(packageName, oldestVer, oldestDate, daysAgo, daysLeft, cooldownDays) + } + } else if pinnedVersion != "" { + if pinnedDate, ok := dates[pinnedVersion]; ok { + if withinCooldown, daysAgo, daysLeft := cooldownIsWithinWindow(pinnedDate, cooldownDays); withinCooldown { + statsCollector.RecordCooldownBlocked(packageName, pinnedVersion, pinnedDate, daysAgo, daysLeft, cooldownDays) + } + } + } +} + // cooldownLatestEligibleVersion returns the most recently published version not in tooNew. func cooldownLatestEligibleVersion(dates map[string]time.Time, tooNew map[string]bool) string { var latest string diff --git a/proxy/interceptors/factory.go b/proxy/interceptors/factory.go index cdcd091..db6ad97 100644 --- a/proxy/interceptors/factory.go +++ b/proxy/interceptors/factory.go @@ -8,12 +8,20 @@ import ( "github.com/safedep/pmg/proxy" ) +// InterceptorContext carries per-execution data from the CLI command into +// the interceptor layer. Unlike the factory's long-lived dependencies +// (analyzer, cache, stats), this holds context specific to the current run. +type InterceptorContext struct { + PinnedVersions map[string]string +} + // InterceptorFactory creates ecosystem-specific interceptors for the proxy type InterceptorFactory struct { analyzer analyzer.PackageVersionAnalyzer cache AnalysisCache statsCollector *AnalysisStatsCollector confirmationChan chan *ConfirmationRequest + execContext InterceptorContext } // NewInterceptorFactory creates a new interceptor factory with shared dependencies @@ -22,12 +30,14 @@ func NewInterceptorFactory( cache AnalysisCache, statsCollector *AnalysisStatsCollector, confirmationChan chan *ConfirmationRequest, + execContext InterceptorContext, ) *InterceptorFactory { return &InterceptorFactory{ analyzer: analyzer, cache: cache, statsCollector: statsCollector, confirmationChan: confirmationChan, + execContext: execContext, } } @@ -41,6 +51,7 @@ func (f *InterceptorFactory) CreateInterceptor(ecosystem packagev1.Ecosystem) (p f.cache, f.statsCollector, f.confirmationChan, + f.execContext, ), nil case packagev1.Ecosystem_ECOSYSTEM_PYPI: @@ -49,6 +60,7 @@ func (f *InterceptorFactory) CreateInterceptor(ecosystem packagev1.Ecosystem) (p f.cache, f.statsCollector, f.confirmationChan, + f.execContext, ), nil default: diff --git a/proxy/interceptors/npm_cooldown.go b/proxy/interceptors/npm_cooldown.go index 4f3a9a4..1020676 100644 --- a/proxy/interceptors/npm_cooldown.go +++ b/proxy/interceptors/npm_cooldown.go @@ -32,7 +32,7 @@ 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) (*proxy.InterceptorResponse, error) { +func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string) (*proxy.InterceptorResponse, error) { log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName) // Force full packument so the response always contains the "time" field. @@ -66,13 +66,7 @@ func (h *npmCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, pa log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)", ctx.RequestID, stripped, packageName, cooldownDays, remaining) - if remaining == 0 && h.statsCollector != nil { - oldestVer, oldestDate := cooldownOldestVersion(dates) - if oldestVer != "" { - _, daysAgo, daysLeft := cooldownIsWithinWindow(oldestDate, cooldownDays) - h.statsCollector.RecordCooldownBlocked(packageName, oldestVer, oldestDate, daysAgo, daysLeft, cooldownDays) - } - } + recordCooldownStats(h.statsCollector, packageName, pinnedVersion, dates, remaining, cooldownDays) // Prevent npm from caching the modified response. Without this, // npm would serve the stripped metadata from cache even after the @@ -215,4 +209,3 @@ func (h *npmCooldownHandler) stripCooldownVersions(body []byte, dates map[string return result, len(tooNew), remaining } - diff --git a/proxy/interceptors/npm_cooldown_test.go b/proxy/interceptors/npm_cooldown_test.go index 1dc7f1a..22016b2 100644 --- a/proxy/interceptors/npm_cooldown_test.go +++ b/proxy/interceptors/npm_cooldown_test.go @@ -301,7 +301,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) + 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")) @@ -323,7 +323,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) + resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -358,7 +358,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) + resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -379,7 +379,7 @@ func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t handler := newNpmCooldownHandler(collector) ctx := makeTestRequestContext("https://registry.npmjs.org/newpkg") - resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5) + resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -411,7 +411,7 @@ func TestNpmCooldown_HandleMetadataRequest_AllVersionsInCooldown_ReportsOldestVe handler := newNpmCooldownHandler(collector) ctx := makeTestRequestContext("https://registry.npmjs.org/multipkg") - resp, err := handler.HandleMetadataRequest(ctx, "multipkg", 100) + resp, err := handler.HandleMetadataRequest(ctx, "multipkg", 100, "") require.NoError(t, err) _, _, _, err = resp.ResponseModifier(200, http.Header{}, body) @@ -428,7 +428,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) + resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -437,10 +437,89 @@ func TestNpmCooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T) assert.Equal(t, body, newBody) } +func TestNpmCooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats(t *testing.T) { + 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), // too new (within 5d cooldown) + } + distTags := map[string]string{"latest": "2.0.0"} + body := buildTestPackument(versions, distTags) + + collector := NewAnalysisStatsCollector() + handler := newNpmCooldownHandler(collector) + ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg") + + resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0") + require.NoError(t, err) + require.NotNil(t, resp.ResponseModifier) + + _, _, _, err = resp.ResponseModifier(200, http.Header{}, body) + require.NoError(t, err) + + blocks := collector.GetCooldownBlocks() + require.Len(t, blocks, 1) + assert.Equal(t, "testpkg", blocks[0].Name) + assert.Equal(t, "2.0.0", blocks[0].Version) + assert.Equal(t, 5, blocks[0].CooldownDays) + + stats := collector.GetStats() + assert.Equal(t, 1, stats.CooldownBlockedCount) + assert.Equal(t, 1, stats.BlockedCount) +} + +func TestNpmCooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t *testing.T) { + now := time.Now() + versions := map[string]time.Time{ + "1.0.0": now.Add(-30 * 24 * time.Hour), // old + "2.0.0": now.Add(-1 * 24 * time.Hour), // too new + } + distTags := map[string]string{"latest": "2.0.0"} + body := buildTestPackument(versions, distTags) + + collector := NewAnalysisStatsCollector() + handler := newNpmCooldownHandler(collector) + ctx := makeTestRequestContext("https://registry.npmjs.org/testpkg") + + resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0") + require.NoError(t, err) + require.NotNil(t, resp.ResponseModifier) + + _, _, _, err = resp.ResponseModifier(200, http.Header{}, body) + require.NoError(t, err) + + blocks := collector.GetCooldownBlocks() + assert.Empty(t, blocks) +} + +func TestNpmCooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBlock(t *testing.T) { + 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), // too new + } + distTags := map[string]string{"latest": "2.0.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) + + _, _, _, err = resp.ResponseModifier(200, http.Header{}, body) + require.NoError(t, err) + + blocks := collector.GetCooldownBlocks() + assert.Empty(t, blocks) +} + func TestNpmCooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) { setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5}) - interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1)) + interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) ctx := makeTestRequestContext("https://registry.npmjs.org/lodash") ctx.Hostname = "registry.npmjs.org" @@ -455,7 +534,7 @@ func TestNpmCooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) { func TestNpmCooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) { setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5}) - interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1)) + interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) ctx := makeTestRequestContext("https://registry.npmjs.org/lodash") ctx.Hostname = "registry.npmjs.org" @@ -476,7 +555,7 @@ func TestNpmCooldown_TarballRequestBypassesCooldown(t *testing.T) { config.Get().InsecureInstallation = true t.Cleanup(func() { config.Get().InsecureInstallation = origInsecure }) - interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1)) + interceptor := NewNpmRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) // Tarball URL has a version component ctx := makeTestRequestContext("https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz") diff --git a/proxy/interceptors/npm_registry.go b/proxy/interceptors/npm_registry.go index 7ddd075..802f406 100644 --- a/proxy/interceptors/npm_registry.go +++ b/proxy/interceptors/npm_registry.go @@ -47,6 +47,7 @@ func NewNpmRegistryInterceptor( cache AnalysisCache, statsCollector *AnalysisStatsCollector, confirmationChan chan *ConfirmationRequest, + execContext InterceptorContext, ) *NpmRegistryInterceptor { return &NpmRegistryInterceptor{ baseRegistryInterceptor: baseRegistryInterceptor{ @@ -55,6 +56,7 @@ func NewNpmRegistryInterceptor( statsCollector: statsCollector, confirmationChan: confirmationChan, circuitBreaker: newAnalyzerCircuitBreaker("malysis-analyzer-npm"), + execContext: execContext, }, cooldownHandler: newNpmCooldownHandler(statsCollector), } @@ -111,7 +113,7 @@ func (i *NpmRegistryInterceptor) HandleRequest(ctx *proxy.RequestContext) (*prox if !pkgInfo.IsFileDownload() { if depCooldownConfig.Enabled { - return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days) + 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()) diff --git a/proxy/interceptors/npm_registry_test.go b/proxy/interceptors/npm_registry_test.go index 9eda84f..7137d93 100644 --- a/proxy/interceptors/npm_registry_test.go +++ b/proxy/interceptors/npm_registry_test.go @@ -8,7 +8,7 @@ import ( ) func TestNpmRegistryInterceptor_ShouldMITM(t *testing.T) { - interceptor := NewNpmRegistryInterceptor(nil, nil, nil, nil) + interceptor := NewNpmRegistryInterceptor(nil, nil, nil, nil, InterceptorContext{}) tests := []struct { name string @@ -31,7 +31,7 @@ func TestNpmRegistryInterceptor_ShouldMITM(t *testing.T) { } func TestNpmRegistryInterceptor_ShouldIntercept(t *testing.T) { - interceptor := NewNpmRegistryInterceptor(nil, nil, nil, nil) + interceptor := NewNpmRegistryInterceptor(nil, nil, nil, nil, InterceptorContext{}) tests := []struct { name string diff --git a/proxy/interceptors/pypi_cooldown.go b/proxy/interceptors/pypi_cooldown.go index 2201bfd..203ac5e 100644 --- a/proxy/interceptors/pypi_cooldown.go +++ b/proxy/interceptors/pypi_cooldown.go @@ -25,7 +25,7 @@ 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. -func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int) (*proxy.InterceptorResponse, error) { +func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string) (*proxy.InterceptorResponse, error) { log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName) // Force PEP 691 JSON so we receive upload-time per file entry. @@ -53,13 +53,7 @@ func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, p log.Infof("[%s] Cooldown: stripped %d version(s) from %s metadata (%d days, %d eligible remain)", ctx.RequestID, stripped, packageName, cooldownDays, remaining) - if remaining == 0 && h.statsCollector != nil { - oldestVer, oldestDate := cooldownOldestVersion(dates) - if oldestVer != "" { - _, daysAgo, daysLeft := cooldownIsWithinWindow(oldestDate, cooldownDays) - h.statsCollector.RecordCooldownBlocked(packageName, oldestVer, oldestDate, daysAgo, daysLeft, cooldownDays) - } - } + recordCooldownStats(h.statsCollector, packageName, pinnedVersion, dates, remaining, cooldownDays) headers.Set("Cache-Control", "no-store") return statusCode, headers, strippedBody, nil diff --git a/proxy/interceptors/pypi_cooldown_test.go b/proxy/interceptors/pypi_cooldown_test.go index 26176f0..56a2938 100644 --- a/proxy/interceptors/pypi_cooldown_test.go +++ b/proxy/interceptors/pypi_cooldown_test.go @@ -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) + 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")) @@ -370,7 +370,7 @@ func TestPyPICooldown_HandleMetadataRequest_NonJSONResponse_FailOpen(t *testing. handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/requests/") - resp, err := handler.HandleMetadataRequest(ctx, "requests", 5) + resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -396,7 +396,7 @@ func TestPyPICooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) { handler := newPypiCooldownHandler(NewAnalysisStatsCollector()) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") - resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5) + resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -433,7 +433,7 @@ func TestPyPICooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/newpkg/") - resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5) + resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -466,7 +466,7 @@ func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") - resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5) + resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -482,7 +482,7 @@ func TestPyPICooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T) handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/badpkg/") - resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5) + resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "") require.NoError(t, err) require.NotNil(t, resp.ResponseModifier) @@ -495,10 +495,98 @@ func TestPyPICooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T) assert.Equal(t, body, retBody) } +func TestPyPICooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats(t *testing.T) { + now := time.Now() + day := 24 * time.Hour + versions := map[string]time.Time{ + "1.0.0": now.Add(-30 * day), + "2.0.0": now.Add(-1 * day), + } + body := buildTestPEP691Response(versions) + + collector := NewAnalysisStatsCollector() + handler := newPypiCooldownHandler(collector) + ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + + resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0") + require.NoError(t, err) + require.NotNil(t, resp.ResponseModifier) + + headers := http.Header{} + headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json") + + _, _, _, err = resp.ResponseModifier(200, headers, body) + require.NoError(t, err) + + blocks := collector.GetCooldownBlocks() + require.Len(t, blocks, 1) + assert.Equal(t, "testpkg", blocks[0].Name) + assert.Equal(t, "2.0.0", blocks[0].Version) + assert.Equal(t, 5, blocks[0].CooldownDays) + + stats := collector.GetStats() + assert.Equal(t, 1, stats.CooldownBlockedCount) + assert.Equal(t, 1, stats.BlockedCount) +} + +func TestPyPICooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t *testing.T) { + now := time.Now() + day := 24 * time.Hour + versions := map[string]time.Time{ + "1.0.0": now.Add(-30 * day), + "2.0.0": now.Add(-1 * day), + } + body := buildTestPEP691Response(versions) + + collector := NewAnalysisStatsCollector() + handler := newPypiCooldownHandler(collector) + ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + + resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0") + require.NoError(t, err) + require.NotNil(t, resp.ResponseModifier) + + headers := http.Header{} + headers.Set("Content-Type", "application/vnd.pypi.simple.v1+json") + + _, _, _, err = resp.ResponseModifier(200, headers, body) + require.NoError(t, err) + + blocks := collector.GetCooldownBlocks() + assert.Empty(t, blocks) +} + +func TestPyPICooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBlock(t *testing.T) { + now := time.Now() + day := 24 * time.Hour + versions := map[string]time.Time{ + "1.0.0": now.Add(-30 * day), + "2.0.0": now.Add(-1 * day), + } + body := buildTestPEP691Response(versions) + + collector := NewAnalysisStatsCollector() + handler := newPypiCooldownHandler(collector) + ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + + 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") + + _, _, _, err = resp.ResponseModifier(200, headers, body) + require.NoError(t, err) + + blocks := collector.GetCooldownBlocks() + assert.Empty(t, blocks) +} + func TestPyPICooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) { setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5}) - interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1)) + interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) ctx := makeTestRequestContext("https://pypi.org/simple/requests/") ctx.Hostname = "pypi.org" @@ -513,7 +601,7 @@ func TestPyPICooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) { func TestPyPICooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) { setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5}) - interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1)) + interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) ctx := makeTestRequestContext("https://pypi.org/simple/requests/") ctx.Hostname = "pypi.org" @@ -528,7 +616,7 @@ func TestPyPICooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) { func TestPyPICooldown_JSONAPIRequest_NotIntercepted(t *testing.T) { setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5}) - interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1)) + interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) ctx := makeTestRequestContext("https://pypi.org/pypi/requests/json") ctx.Hostname = "pypi.org" @@ -547,7 +635,7 @@ func TestPyPICooldown_FileDownloadBypassesCooldown(t *testing.T) { config.Get().InsecureInstallation = true t.Cleanup(func() { config.Get().InsecureInstallation = origInsecure }) - interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1)) + interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) ctx := makeTestRequestContext("https://files.pythonhosted.org/packages/ab/cd/ef/requests-2.31.0-py3-none-any.whl") ctx.Hostname = "files.pythonhosted.org" diff --git a/proxy/interceptors/pypi_registry.go b/proxy/interceptors/pypi_registry.go index 9a15846..cd00c9f 100644 --- a/proxy/interceptors/pypi_registry.go +++ b/proxy/interceptors/pypi_registry.go @@ -50,7 +50,16 @@ func NewPypiRegistryInterceptor( cache AnalysisCache, statsCollector *AnalysisStatsCollector, confirmationChan chan *ConfirmationRequest, + execContext InterceptorContext, ) *PypiRegistryInterceptor { + // Re-key pinned versions to the normalized form (lowercase, underscores→hyphens) + // so lookups by URL-parsed package name match correctly. + normalizedPinned := make(map[string]string, len(execContext.PinnedVersions)) + for name, version := range execContext.PinnedVersions { + normalizedPinned[denormalizePyPIPackageName(name)] = version + } + execContext.PinnedVersions = normalizedPinned + return &PypiRegistryInterceptor{ baseRegistryInterceptor: baseRegistryInterceptor{ analyzer: analyzer, @@ -58,6 +67,7 @@ func NewPypiRegistryInterceptor( statsCollector: statsCollector, confirmationChan: confirmationChan, circuitBreaker: newAnalyzerCircuitBreaker("malysis-analyzer-pypi"), + execContext: execContext, }, cooldownHandler: newPypiCooldownHandler(statsCollector), } @@ -116,7 +126,7 @@ 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/") { - return i.cooldownHandler.HandleMetadataRequest(ctx, pkgInfo.GetName(), depCooldownConfig.Days) + 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()) diff --git a/proxy/interceptors/pypi_registry_test.go b/proxy/interceptors/pypi_registry_test.go index d379a1c..d538ed6 100644 --- a/proxy/interceptors/pypi_registry_test.go +++ b/proxy/interceptors/pypi_registry_test.go @@ -8,7 +8,7 @@ import ( ) func TestPypiRegistryInterceptor_ShouldMITM(t *testing.T) { - interceptor := NewPypiRegistryInterceptor(nil, nil, nil, nil) + interceptor := NewPypiRegistryInterceptor(nil, nil, nil, nil, InterceptorContext{}) tests := []struct { name string diff --git a/proxy/interceptors/pypi_url_parser.go b/proxy/interceptors/pypi_url_parser.go index 38fb81c..c085449 100644 --- a/proxy/interceptors/pypi_url_parser.go +++ b/proxy/interceptors/pypi_url_parser.go @@ -370,5 +370,8 @@ func extractNameVersionFromParts(parts []string) (string, string) { func denormalizePyPIPackageName(name string) string { // Convert underscores to hyphens (common PyPI convention) // Keep lowercase as that's the normalized form - return strings.ReplaceAll(strings.ToLower(name), "_", "-") + name = strings.ToLower(name) + name = strings.ReplaceAll(name, "_", "-") + name = strings.ReplaceAll(name, ".", "-") + return name }