diff --git a/config/config.go b/config/config.go index f8ccf5d..20121eb 100644 --- a/config/config.go +++ b/config/config.go @@ -795,6 +795,12 @@ func Get() *RuntimeConfig { return globalConfig } +// AdvisoryMessage returns the org-configured advisory message appended to +// policy block output. Empty when not configured. +func AdvisoryMessage() string { + return globalConfig.Config.AdvisoryMessage +} + func ConfigureSandbox(mayDownloadPackages bool) { if globalConfig.Config.Sandbox.Enabled { // Apply sandbox to all commands if EnforceAlways=true, otherwise only to diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index d5bc1a0..3b21594 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -348,6 +348,8 @@ func (f *proxyFlow) createAndStartProxyServer( proxyConfig := proxy.DefaultProxyConfig() proxyConfig.CertManager = certMgr proxyConfig.Interceptors = interceptorsList + presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage} + proxyConfig.BlockMessageRenderer = presenter.BlockMessage proxyServer, err := proxy.NewProxyServer(proxyConfig) if err != nil { diff --git a/internal/proxyserver/server.go b/internal/proxyserver/server.go index da3040e..707bac6 100644 --- a/internal/proxyserver/server.go +++ b/internal/proxyserver/server.go @@ -16,6 +16,7 @@ import ( "github.com/safedep/pmg/internal/audit" "github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/localstore" + "github.com/safedep/pmg/internal/ui" pmgproxy "github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy/certmanager" "github.com/safedep/pmg/proxy/interceptors" @@ -115,6 +116,8 @@ func Run(ctx context.Context, cfg *config.RuntimeConfig, statePath, host string, proxyConfig.ListenAddr = listenAddr(host, port) proxyConfig.CertManager = certMgr proxyConfig.Interceptors = interceptorList + presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage} + proxyConfig.BlockMessageRenderer = presenter.BlockMessage server, err := pmgproxy.NewProxyServer(proxyConfig) if err != nil { diff --git a/internal/ui/proxy_presenter.go b/internal/ui/proxy_presenter.go new file mode 100644 index 0000000..2f46ffe --- /dev/null +++ b/internal/ui/proxy_presenter.go @@ -0,0 +1,71 @@ +package ui + +import ( + "fmt" + "strings" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/safedep/pmg/proxy" +) + +// ecosystemLabel maps the ecosystem enum to the label users know the +// registry by (npm, pypi, go, ...) instead of the raw enum name. +func ecosystemLabel(ecosystem packagev1.Ecosystem) string { + return strings.ToLower(strings.TrimPrefix(ecosystem.String(), "ECOSYSTEM_")) +} + +// ProxyPresenter composes all user-facing text authored by the proxy layer. +// Interceptors return structured decisions; any new proxy-emitted message +// belongs here, not in the proxy layer. +type ProxyPresenter struct { + // Advisory returns the org-configured advisory message appended to + // policy block messages. Read at render time so config changes apply + // to subsequent blocks. nil means no advisory. + Advisory func() string +} + +// BlockMessage renders the response body for a blocked proxy request +// from the interceptor's structured block decision. +func (p ProxyPresenter) BlockMessage(reason proxy.BlockReason, blockCtx *proxy.BlockContext) string { + if blockCtx == nil { + return "" + } + + ecosystem := ecosystemLabel(blockCtx.Ecosystem) + + var message string + switch reason { + case proxy.BlockReasonMalware, proxy.BlockReasonUserDeclined: + prefix := "Malicious package blocked" + if reason == proxy.BlockReasonUserDeclined { + prefix = "Installation blocked by user" + } + + message = fmt.Sprintf("%s: %s/%s@%s\n\nReason: %s", + prefix, ecosystem, blockCtx.PackageName, blockCtx.PackageVersion, blockCtx.MalwareSummary) + if blockCtx.MalwareReferenceURL != "" { + message += "\n\nReference: " + blockCtx.MalwareReferenceURL + } + + case proxy.BlockReasonConfirmationFailed: + // Operational failure rather than a policy decision; the advisory + // message is intentionally not appended. + return fmt.Sprintf("Failed to get user confirmation for suspicious package %s/%s@%s", + ecosystem, blockCtx.PackageName, blockCtx.PackageVersion) + + case proxy.BlockReasonDependencyCooldown: + message = fmt.Sprintf("Package blocked by dependency cooldown: %s/%s@%s\n\nPublished %d day(s) ago; cooldown window is %d day(s) (%d remaining).", + ecosystem, blockCtx.PackageName, blockCtx.PackageVersion, + blockCtx.CooldownDaysAgo, blockCtx.CooldownDays, blockCtx.CooldownDaysLeft) + + default: + return "" + } + + if p.Advisory != nil { + if advisory := p.Advisory(); advisory != "" { + message += "\n\n" + advisory + } + } + return message +} diff --git a/internal/ui/proxy_presenter_test.go b/internal/ui/proxy_presenter_test.go new file mode 100644 index 0000000..6e68b15 --- /dev/null +++ b/internal/ui/proxy_presenter_test.go @@ -0,0 +1,130 @@ +package ui + +import ( + "testing" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/safedep/pmg/proxy" + "github.com/stretchr/testify/assert" +) + +func TestEcosystemLabel(t *testing.T) { + tests := []struct { + ecosystem packagev1.Ecosystem + expected string + }{ + {packagev1.Ecosystem_ECOSYSTEM_NPM, "npm"}, + {packagev1.Ecosystem_ECOSYSTEM_PYPI, "pypi"}, + {packagev1.Ecosystem_ECOSYSTEM_GO, "go"}, + {packagev1.Ecosystem_ECOSYSTEM_RUBYGEMS, "rubygems"}, + {packagev1.Ecosystem_ECOSYSTEM_GITHUB_ACTIONS, "github_actions"}, + {packagev1.Ecosystem_ECOSYSTEM_UNSPECIFIED, "unspecified"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, ecosystemLabel(tt.ecosystem)) + }) + } +} + +func TestProxyPresenterBlockMessage(t *testing.T) { + malwareCtx := &proxy.BlockContext{ + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, + PackageName: "evil", + PackageVersion: "1.0.0", + MalwareSummary: "Contains known malware", + MalwareReferenceURL: "https://example.com/malware-report", + } + + tests := []struct { + name string + reason proxy.BlockReason + blockCtx *proxy.BlockContext + advisory string + expected string + }{ + { + name: "malware", + reason: proxy.BlockReasonMalware, + blockCtx: malwareCtx, + expected: "Malicious package blocked: npm/evil@1.0.0\n\nReason: Contains known malware\n\nReference: https://example.com/malware-report", + }, + { + name: "malware with advisory", + reason: proxy.BlockReasonMalware, + blockCtx: malwareCtx, + advisory: "Contact #security-help", + expected: "Malicious package blocked: npm/evil@1.0.0\n\nReason: Contains known malware\n\nReference: https://example.com/malware-report\n\nContact #security-help", + }, + { + name: "user declined", + reason: proxy.BlockReasonUserDeclined, + blockCtx: malwareCtx, + advisory: "Contact #security-help", + expected: "Installation blocked by user: npm/evil@1.0.0\n\nReason: Contains known malware\n\nReference: https://example.com/malware-report\n\nContact #security-help", + }, + { + name: "malware without reference URL omits reference line", + reason: proxy.BlockReasonMalware, + blockCtx: &proxy.BlockContext{ + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, + PackageName: "evil", + PackageVersion: "1.0.0", + MalwareSummary: "Contains known malware", + }, + advisory: "Contact #security-help", + expected: "Malicious package blocked: npm/evil@1.0.0\n\nReason: Contains known malware\n\nContact #security-help", + }, + { + name: "confirmation failed carries no advisory", + reason: proxy.BlockReasonConfirmationFailed, + blockCtx: malwareCtx, + advisory: "Contact #security-help", + expected: "Failed to get user confirmation for suspicious package npm/evil@1.0.0", + }, + { + name: "dependency cooldown", + reason: proxy.BlockReasonDependencyCooldown, + blockCtx: &proxy.BlockContext{ + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_GO, + PackageName: "example.com/fresh", + PackageVersion: "v1.1.0", + CooldownDays: 7, + CooldownDaysAgo: 2, + CooldownDaysLeft: 5, + }, + advisory: "Request an exemption at go/pmg-exceptions", + expected: "Package blocked by dependency cooldown: go/example.com/fresh@v1.1.0\n\nPublished 2 day(s) ago; cooldown window is 7 day(s) (5 remaining).\n\nRequest an exemption at go/pmg-exceptions", + }, + { + name: "nil context", + reason: proxy.BlockReasonMalware, + blockCtx: nil, + expected: "", + }, + { + name: "no reason", + reason: proxy.BlockReasonNone, + blockCtx: malwareCtx, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := ProxyPresenter{Advisory: func() string { return tt.advisory }} + assert.Equal(t, tt.expected, p.BlockMessage(tt.reason, tt.blockCtx)) + }) + } +} + +func TestProxyPresenterNilAdvisory(t *testing.T) { + message := ProxyPresenter{}.BlockMessage(proxy.BlockReasonMalware, &proxy.BlockContext{ + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM, + PackageName: "evil", + PackageVersion: "1.0.0", + MalwareSummary: "verified malware", + }) + assert.Equal(t, "Malicious package blocked: npm/evil@1.0.0\n\nReason: verified malware", message) +} diff --git a/proxy/interceptor.go b/proxy/interceptor.go index f377576..c8d143f 100644 --- a/proxy/interceptor.go +++ b/proxy/interceptor.go @@ -4,6 +4,8 @@ import ( "net/http" "net/url" "time" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" ) // ResponseAction determines how the proxy should handle a request @@ -41,12 +43,46 @@ type RequestContext struct { Data map[string]interface{} } +// BlockReason identifies why an interceptor blocked a request +type BlockReason int + +const ( + BlockReasonNone BlockReason = iota + BlockReasonMalware + BlockReasonUserDeclined + BlockReasonConfirmationFailed + BlockReasonDependencyCooldown +) + +// BlockContext carries the structured facts of a block decision so a +// presentation layer can render the user-facing message. Interceptors +// populate it instead of composing message text themselves. +type BlockContext struct { + Ecosystem packagev1.Ecosystem + PackageName string + PackageVersion string + + // For BlockReasonMalware and BlockReasonUserDeclined + MalwareSummary string + MalwareReferenceURL string + + // For BlockReasonDependencyCooldown + CooldownDays int + CooldownDaysAgo int + CooldownDaysLeft int +} + // InterceptorResponse defines how the proxy should handle the request type InterceptorResponse struct { // Action to take Action ResponseAction - // For Action = Block: error message to return + // For Action = Block: why and what was blocked. The proxy renders the + // response body from these via ProxyConfig.BlockMessageRenderer. + BlockReason BlockReason + BlockContext *BlockContext + + // BlockMessage overrides the rendered message when non-empty BlockMessage string BlockCode int diff --git a/proxy/interceptors/base_registry.go b/proxy/interceptors/base_registry.go index 70e72dd..e106667 100644 --- a/proxy/interceptors/base_registry.go +++ b/proxy/interceptors/base_registry.go @@ -101,15 +101,6 @@ func (b *baseRegistryInterceptor) fastAllow( return nil, false } -// appendAdvisoryMessage appends the org-configured advisory_message, when set, -// to a block message body. -func appendAdvisoryMessage(message, advisory string) string { - if advisory == "" { - return message - } - return message + "\n\n" + advisory -} - // 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( @@ -203,16 +194,17 @@ func (b *baseRegistryInterceptor) handleAnalysisResult( b.statsCollector.RecordBlocked(result) } - message := appendAdvisoryMessage(fmt.Sprintf("Malicious package blocked: %s/%s@%s\n\nReason: %s\n\nReference: %s", - ecosystem.String(), - packageName, packageVersion, - result.Summary, - result.ReferenceURL), config.Get().Config.AdvisoryMessage) - return &proxy.InterceptorResponse{ - Action: proxy.ActionBlock, - BlockCode: http.StatusForbidden, - BlockMessage: message, + Action: proxy.ActionBlock, + BlockCode: http.StatusForbidden, + BlockReason: proxy.BlockReasonMalware, + BlockContext: &proxy.BlockContext{ + Ecosystem: ecosystem, + PackageName: packageName, + PackageVersion: packageVersion, + MalwareSummary: result.Summary, + MalwareReferenceURL: result.ReferenceURL, + }, }, nil case analyzer.ActionConfirm: @@ -227,9 +219,14 @@ func (b *baseRegistryInterceptor) handleAnalysisResult( } return &proxy.InterceptorResponse{ - Action: proxy.ActionBlock, - BlockCode: http.StatusForbidden, - BlockMessage: fmt.Sprintf("Failed to get user confirmation for suspicious package %s/%s@%s", ecosystem.String(), packageName, packageVersion), + Action: proxy.ActionBlock, + BlockCode: http.StatusForbidden, + BlockReason: proxy.BlockReasonConfirmationFailed, + BlockContext: &proxy.BlockContext{ + Ecosystem: ecosystem, + PackageName: packageName, + PackageVersion: packageVersion, + }, }, nil } @@ -242,16 +239,17 @@ func (b *baseRegistryInterceptor) handleAnalysisResult( b.statsCollector.RecordUserCancelled(result) } - message := appendAdvisoryMessage(fmt.Sprintf("Installation blocked by user: %s/%s@%s\n\nReason: %s\n\nReference: %s", - ecosystem.String(), - packageName, packageVersion, - result.Summary, - result.ReferenceURL), config.Get().Config.AdvisoryMessage) - return &proxy.InterceptorResponse{ - Action: proxy.ActionBlock, - BlockCode: http.StatusForbidden, - BlockMessage: message, + Action: proxy.ActionBlock, + BlockCode: http.StatusForbidden, + BlockReason: proxy.BlockReasonUserDeclined, + BlockContext: &proxy.BlockContext{ + Ecosystem: ecosystem, + PackageName: packageName, + PackageVersion: packageVersion, + MalwareSummary: result.Summary, + MalwareReferenceURL: result.ReferenceURL, + }, }, nil } diff --git a/proxy/interceptors/base_registry_test.go b/proxy/interceptors/base_registry_test.go index ae2ce8c..4373c21 100644 --- a/proxy/interceptors/base_registry_test.go +++ b/proxy/interceptors/base_registry_test.go @@ -62,15 +62,15 @@ func TestFastAllow_InsecureReturnsAllow(t *testing.T) { func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { tests := []struct { - name string - ecosystem packagev1.Ecosystem - packageName string - packageVersion string - analysisResult *analyzer.PackageVersionAnalysisResult - userConfirms bool - expectedAction proxy.ResponseAction - expectedBlockCode int - expectBlockMessage bool + name string + ecosystem packagev1.Ecosystem + packageName string + packageVersion string + analysisResult *analyzer.PackageVersionAnalysisResult + userConfirms bool + expectedAction proxy.ResponseAction + expectedBlockCode int + expectedBlockReason proxy.BlockReason }{ { name: "ActionBlock - malicious package", @@ -82,9 +82,9 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { Summary: "Contains known malware", ReferenceURL: "https://example.com/malware-report", }, - expectedAction: proxy.ActionBlock, - expectedBlockCode: http.StatusForbidden, - expectBlockMessage: true, + expectedAction: proxy.ActionBlock, + expectedBlockCode: http.StatusForbidden, + expectedBlockReason: proxy.BlockReasonMalware, }, { name: "ActionConfirm - user confirms installation", @@ -96,10 +96,10 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { Summary: "Suspicious behavior detected", ReferenceURL: "https://example.com/suspicious-report", }, - userConfirms: true, - expectedAction: proxy.ActionAllow, - expectedBlockCode: 0, - expectBlockMessage: false, + userConfirms: true, + expectedAction: proxy.ActionAllow, + expectedBlockCode: 0, + expectedBlockReason: proxy.BlockReasonNone, }, { name: "ActionConfirm - user declines installation", @@ -111,10 +111,10 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { Summary: "Suspicious behavior detected", ReferenceURL: "https://example.com/suspicious-report", }, - userConfirms: false, - expectedAction: proxy.ActionBlock, - expectedBlockCode: http.StatusForbidden, - expectBlockMessage: true, + userConfirms: false, + expectedAction: proxy.ActionBlock, + expectedBlockCode: http.StatusForbidden, + expectedBlockReason: proxy.BlockReasonUserDeclined, }, // Note: Timeout test case is skipped as it would require waiting 5 minutes // The timeout behavior is covered by the implementation but not tested here @@ -129,9 +129,9 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { Summary: "Package is safe", ReferenceURL: "https://example.com/safe-report", }, - expectedAction: proxy.ActionAllow, - expectedBlockCode: 0, - expectBlockMessage: false, + expectedAction: proxy.ActionAllow, + expectedBlockCode: 0, + expectedBlockReason: proxy.BlockReasonNone, }, { name: "ActionUnknown - default to allow", @@ -143,9 +143,9 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { Summary: "Unknown action", ReferenceURL: "https://example.com/unknown-report", }, - expectedAction: proxy.ActionAllow, - expectedBlockCode: 0, - expectBlockMessage: false, + expectedAction: proxy.ActionAllow, + expectedBlockCode: 0, + expectedBlockReason: proxy.BlockReasonNone, }, { name: "ActionBlock - pypi ecosystem", @@ -157,9 +157,9 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { Summary: "Malicious PyPI package", ReferenceURL: "https://example.com/pypi-malware", }, - expectedAction: proxy.ActionBlock, - expectedBlockCode: http.StatusForbidden, - expectBlockMessage: true, + expectedAction: proxy.ActionBlock, + expectedBlockCode: http.StatusForbidden, + expectedBlockReason: proxy.BlockReasonMalware, }, } @@ -200,35 +200,25 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) { assert.NoError(t, err) assert.Equal(t, tt.expectedAction, response.Action) assert.Equal(t, tt.expectedBlockCode, response.BlockCode) - assert.Equal(t, tt.expectBlockMessage, response.BlockMessage != "") + assert.Equal(t, tt.expectedBlockReason, response.BlockReason) + assert.Empty(t, response.BlockMessage) + + switch tt.expectedBlockReason { + case proxy.BlockReasonNone: + assert.Nil(t, response.BlockContext) + case proxy.BlockReasonMalware, proxy.BlockReasonUserDeclined: + require.NotNil(t, response.BlockContext) + assert.Equal(t, tt.ecosystem, response.BlockContext.Ecosystem) + assert.Equal(t, tt.packageName, response.BlockContext.PackageName) + assert.Equal(t, tt.packageVersion, response.BlockContext.PackageVersion) + assert.Equal(t, tt.analysisResult.Summary, response.BlockContext.MalwareSummary) + assert.Equal(t, tt.analysisResult.ReferenceURL, response.BlockContext.MalwareReferenceURL) + default: + require.NotNil(t, response.BlockContext) + assert.Equal(t, tt.ecosystem, response.BlockContext.Ecosystem) + assert.Equal(t, tt.packageName, response.BlockContext.PackageName) + assert.Equal(t, tt.packageVersion, response.BlockContext.PackageVersion) + } }) } } - -func TestAppendAdvisoryMessage(t *testing.T) { - assert.Equal(t, "base", appendAdvisoryMessage("base", "")) - assert.Equal(t, "base\n\ncustom", appendAdvisoryMessage("base", "custom")) -} - -func TestHandleAnalysisResultBlockCarriesAdvisoryMessage(t *testing.T) { - origMsg := pmgconfig.Get().Config.AdvisoryMessage - pmgconfig.Get().Config.AdvisoryMessage = "Contact #security-help" - t.Cleanup(func() { pmgconfig.Get().Config.AdvisoryMessage = origMsg }) - - b := &baseRegistryInterceptor{} - ctx := makeTestRequestContext("https://registry.npmjs.org/evil/-/evil-1.0.0.tgz") - - result := &analyzer.PackageVersionAnalysisResult{ - PackageVersion: &packagev1.PackageVersion{ - Package: &packagev1.Package{Name: "evil", Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM}, - Version: "1.0.0", - }, - Action: analyzer.ActionBlock, - Summary: "verified malware", - } - - resp, err := b.handleAnalysisResult(ctx, packagev1.Ecosystem_ECOSYSTEM_NPM, "evil", "1.0.0", result) - require.NoError(t, err) - assert.Equal(t, proxy.ActionBlock, resp.Action) - assert.Contains(t, resp.BlockMessage, "Contact #security-help") -} diff --git a/proxy/interceptors/go_cooldown.go b/proxy/interceptors/go_cooldown.go index 980bc42..efb4b1b 100644 --- a/proxy/interceptors/go_cooldown.go +++ b/proxy/interceptors/go_cooldown.go @@ -126,15 +126,18 @@ func (h *goCooldownHandler) CheckZipDownload(ctx *proxy.RequestContext, baseURL, pv.SetVersion(version) audit.LogDependencyCooldown(pv, publishTime, cooldownDays, daysAgo, daysLeft) - message := appendAdvisoryMessage( - fmt.Sprintf("Package blocked by dependency cooldown: GO/%s@%s\n\nPublished %d day(s) ago; cooldown window is %d day(s) (%d remaining).", - module, version, daysAgo, cooldownDays, daysLeft), - pmgconfig.Get().Config.AdvisoryMessage) - return &proxy.InterceptorResponse{ - Action: proxy.ActionBlock, - BlockCode: http.StatusForbidden, - BlockMessage: message, + Action: proxy.ActionBlock, + BlockCode: http.StatusForbidden, + BlockReason: proxy.BlockReasonDependencyCooldown, + BlockContext: &proxy.BlockContext{ + Ecosystem: packagev1.Ecosystem_ECOSYSTEM_GO, + PackageName: module, + PackageVersion: version, + CooldownDays: cooldownDays, + CooldownDaysAgo: daysAgo, + CooldownDaysLeft: daysLeft, + }, }, true } diff --git a/proxy/interceptors/go_cooldown_test.go b/proxy/interceptors/go_cooldown_test.go index 322c9b8..2c695eb 100644 --- a/proxy/interceptors/go_cooldown_test.go +++ b/proxy/interceptors/go_cooldown_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/pmg/proxy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,6 +40,15 @@ func TestGoCooldownCheckZipDownloadSideFetch(t *testing.T) { require.True(t, handled) assert.Equal(t, proxy.ActionBlock, resp.Action) assert.Equal(t, http.StatusForbidden, resp.BlockCode) + assert.Equal(t, proxy.BlockReasonDependencyCooldown, resp.BlockReason) + + require.NotNil(t, resp.BlockContext) + assert.Equal(t, packagev1.Ecosystem_ECOSYSTEM_GO, resp.BlockContext.Ecosystem) + assert.Equal(t, "example.com/fresh", resp.BlockContext.PackageName) + assert.Equal(t, "v1.1.0", resp.BlockContext.PackageVersion) + assert.Equal(t, 7, resp.BlockContext.CooldownDays) + assert.Equal(t, 1, resp.BlockContext.CooldownDaysAgo) + assert.Equal(t, 6, resp.BlockContext.CooldownDaysLeft) }) t.Run("fails open when the out-of-band fetch fails", func(t *testing.T) { diff --git a/proxy/proxy.go b/proxy/proxy.go index d054964..30d635a 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -62,6 +62,11 @@ type ProxyConfig struct { // Interceptors Interceptors []Interceptor + // BlockMessageRenderer composes the response body for blocked requests + // from the interceptor's structured block decision. nil falls back to + // the generic block message. + BlockMessageRenderer func(BlockReason, *BlockContext) string + // Other configuration EnableMITM bool RequestTimeout time.Duration @@ -561,6 +566,9 @@ func (ps *proxyServer) registerHandlers() { } message := resp.BlockMessage + if message == "" && ps.config.BlockMessageRenderer != nil { + message = ps.config.BlockMessageRenderer(resp.BlockReason, resp.BlockContext) + } if message == "" { message = "Blocked by proxy interceptor" } diff --git a/test/proxye2e/harness.go b/test/proxye2e/harness.go index ccfa14e..65277b8 100644 --- a/test/proxye2e/harness.go +++ b/test/proxye2e/harness.go @@ -14,7 +14,9 @@ import ( packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" "github.com/safedep/pmg/analyzer" + "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/models" + "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/proxy" "github.com/safedep/pmg/proxy/certmanager" "github.com/safedep/pmg/proxy/interceptors" @@ -151,6 +153,8 @@ func buildProxy(t *testing.T, certMgr certmanager.CertificateManager, upstreamAd cfg := proxy.DefaultProxyConfig() cfg.CertManager = certMgr cfg.Interceptors = interceptorList + presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage} + cfg.BlockMessageRenderer = presenter.BlockMessage // All upstream connections — MITM'd round-trips and CONNECT tunnels for // non-MITM hosts alike — terminate at the mock registry, so no test reaches