diff --git a/README.md b/README.md index f47d1d0..f6384ad 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Recent compromises in popular ecosystems: - [**telnyx 4.87.2**](https://safedep.io/malicious-telnyx-pypi-compromise/) - a legitimate telecom SDK hijacked on PyPI - [**pino-sdk-v2**](https://safedep.io/malicious-npm-package-pino-sdk-v2-env-exfiltration/) - a typosquat package disguised as the popular pino logger -PMG intercepts every package install and checks it for malware **before** code executes. Install it once, and PMG covers every `npm install`, `pip install`, and `poetry add` after that. + +**PMG is free, open source (Apache 2.0), and requires no account or API key.** It intercepts every package install and checks it against [SafeDep's free community API](https://safedep.io) for known malware **before** code executes. Install it once, and it covers every `npm install`, `pip install`, and `poetry add` after that. > Featured in [tl;dr sec](https://tldrsec.com/p/tldr-sec-316). diff --git a/proxy/interceptors/pypi_cooldown.go b/proxy/interceptors/pypi_cooldown.go index b960222..8ce0daa 100644 --- a/proxy/interceptors/pypi_cooldown.go +++ b/proxy/interceptors/pypi_cooldown.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" @@ -26,9 +27,21 @@ 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) (*proxy.InterceptorResponse, error) { log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName) + originalAccept := ctx.Headers.Get("Accept") + clientSupportsPEP691 := strings.Contains(originalAccept, pypiSimpleAPIContentType) + + if !clientSupportsPEP691 { + log.Warnf("[%s] Cooldown: client does not support PEP 691 JSON (Accept: %s), "+ + "cooldown cannot be enforced for %s. Upgrade pip to 22.3+ for cooldown support.", + ctx.RequestID, originalAccept, packageName) + return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil + } + // Force PEP 691 JSON so we receive upload-time per file entry. ctx.Headers.Set("Accept", pypiSimpleAPIContentType) // Prevent compression so the response body can be parsed as JSON directly. diff --git a/proxy/interceptors/pypi_cooldown_test.go b/proxy/interceptors/pypi_cooldown_test.go index 56a2938..301c48f 100644 --- a/proxy/interceptors/pypi_cooldown_test.go +++ b/proxy/interceptors/pypi_cooldown_test.go @@ -352,7 +352,7 @@ func TestStripCooldownFiles_UnparseableFilename_KeepFile(t *testing.T) { func TestPyPICooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) { handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/requests/") - ctx.Headers.Set("Accept", "text/html") + ctx.Headers.Set("Accept", "application/vnd.pypi.simple.v1+json, text/html;q=0.01") ctx.Headers.Set("Accept-Encoding", "gzip") ctx.Headers.Set("If-None-Match", `"abc123"`) ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT") @@ -366,9 +366,22 @@ func TestPyPICooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) { assert.Empty(t, ctx.Headers.Get("If-Modified-Since")) } +func TestPyPICooldown_HandleMetadataRequest_ClientWithoutPEP691_SkipsCooldown(t *testing.T) { + handler := newPypiCooldownHandler(nil) + ctx := makeTestRequestContext("https://pypi.org/simple/requests/") + ctx.Headers.Set("Accept", "text/html") + + resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "") + require.NoError(t, err) + assert.Equal(t, proxy.ActionAllow, resp.Action) + assert.Nil(t, resp.ResponseModifier) + assert.Equal(t, "text/html", ctx.Headers.Get("Accept")) +} + func TestPyPICooldown_HandleMetadataRequest_NonJSONResponse_FailOpen(t *testing.T) { handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/requests/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "") require.NoError(t, err) @@ -395,6 +408,7 @@ func TestPyPICooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) { 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) @@ -432,6 +446,7 @@ func TestPyPICooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t collector := NewAnalysisStatsCollector() handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/newpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "") require.NoError(t, err) @@ -465,6 +480,7 @@ func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) @@ -481,6 +497,7 @@ func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t func TestPyPICooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T) { handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/badpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "") require.NoError(t, err) @@ -507,6 +524,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats collector := NewAnalysisStatsCollector() handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0") require.NoError(t, err) @@ -541,6 +559,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t collector := NewAnalysisStatsCollector() handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0") require.NoError(t, err) @@ -568,6 +587,7 @@ func TestPyPICooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBloc collector := NewAnalysisStatsCollector() handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) @@ -590,7 +610,7 @@ func TestPyPICooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) { ctx := makeTestRequestContext("https://pypi.org/simple/requests/") ctx.Hostname = "pypi.org" - ctx.Headers.Set("Accept", "text/html") + ctx.Headers.Set("Accept", "application/vnd.pypi.simple.v1+json, text/html;q=0.01") resp, err := interceptor.HandleRequest(ctx) require.NoError(t, err) @@ -598,6 +618,21 @@ func TestPyPICooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) { assert.Equal(t, "application/vnd.pypi.simple.v1+json", ctx.Headers.Get("Accept")) } +func TestPyPICooldown_InterceptorDelegation_CooldownEnabled_OldPip(t *testing.T) { + setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5}) + + interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) + + ctx := makeTestRequestContext("https://pypi.org/simple/requests/") + ctx.Hostname = "pypi.org" + ctx.Headers.Set("Accept", "text/html") + + resp, err := interceptor.HandleRequest(ctx) + require.NoError(t, err) + assert.Equal(t, proxy.ActionAllow, resp.Action) + assert.Equal(t, "text/html", ctx.Headers.Get("Accept")) +} + func TestPyPICooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) { setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5})