mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
refactor: move proxy block message rendering to presentation layer (#364)
* refactor: move proxy block message rendering to presentation layer * refactor: introduce ui.ProxyPresenter with injected advisory source * feat: friendly ecosystem labels in proxy block messages * test: cover ecosystemLabel derivation * fix: address review feedback on block context assertions and empty reference line
This commit is contained in:
@@ -795,6 +795,12 @@ func Get() *RuntimeConfig {
|
|||||||
return globalConfig
|
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) {
|
func ConfigureSandbox(mayDownloadPackages bool) {
|
||||||
if globalConfig.Config.Sandbox.Enabled {
|
if globalConfig.Config.Sandbox.Enabled {
|
||||||
// Apply sandbox to all commands if EnforceAlways=true, otherwise only to
|
// Apply sandbox to all commands if EnforceAlways=true, otherwise only to
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ func (f *proxyFlow) createAndStartProxyServer(
|
|||||||
proxyConfig := proxy.DefaultProxyConfig()
|
proxyConfig := proxy.DefaultProxyConfig()
|
||||||
proxyConfig.CertManager = certMgr
|
proxyConfig.CertManager = certMgr
|
||||||
proxyConfig.Interceptors = interceptorsList
|
proxyConfig.Interceptors = interceptorsList
|
||||||
|
presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage}
|
||||||
|
proxyConfig.BlockMessageRenderer = presenter.BlockMessage
|
||||||
|
|
||||||
proxyServer, err := proxy.NewProxyServer(proxyConfig)
|
proxyServer, err := proxy.NewProxyServer(proxyConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/safedep/pmg/internal/audit"
|
"github.com/safedep/pmg/internal/audit"
|
||||||
"github.com/safedep/pmg/internal/flows"
|
"github.com/safedep/pmg/internal/flows"
|
||||||
"github.com/safedep/pmg/internal/localstore"
|
"github.com/safedep/pmg/internal/localstore"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
pmgproxy "github.com/safedep/pmg/proxy"
|
pmgproxy "github.com/safedep/pmg/proxy"
|
||||||
"github.com/safedep/pmg/proxy/certmanager"
|
"github.com/safedep/pmg/proxy/certmanager"
|
||||||
"github.com/safedep/pmg/proxy/interceptors"
|
"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.ListenAddr = listenAddr(host, port)
|
||||||
proxyConfig.CertManager = certMgr
|
proxyConfig.CertManager = certMgr
|
||||||
proxyConfig.Interceptors = interceptorList
|
proxyConfig.Interceptors = interceptorList
|
||||||
|
presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage}
|
||||||
|
proxyConfig.BlockMessageRenderer = presenter.BlockMessage
|
||||||
|
|
||||||
server, err := pmgproxy.NewProxyServer(proxyConfig)
|
server, err := pmgproxy.NewProxyServer(proxyConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+37
-1
@@ -4,6 +4,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResponseAction determines how the proxy should handle a request
|
// ResponseAction determines how the proxy should handle a request
|
||||||
@@ -41,12 +43,46 @@ type RequestContext struct {
|
|||||||
Data map[string]interface{}
|
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
|
// InterceptorResponse defines how the proxy should handle the request
|
||||||
type InterceptorResponse struct {
|
type InterceptorResponse struct {
|
||||||
// Action to take
|
// Action to take
|
||||||
Action ResponseAction
|
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
|
BlockMessage string
|
||||||
BlockCode int
|
BlockCode int
|
||||||
|
|
||||||
|
|||||||
@@ -101,15 +101,6 @@ func (b *baseRegistryInterceptor) fastAllow(
|
|||||||
return nil, false
|
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
|
// analyzePackage analyzes a package using the configured analyzer with caching
|
||||||
// This method is ecosystem-agnostic and can be used by any registry interceptor
|
// This method is ecosystem-agnostic and can be used by any registry interceptor
|
||||||
func (b *baseRegistryInterceptor) analyzePackage(
|
func (b *baseRegistryInterceptor) analyzePackage(
|
||||||
@@ -203,16 +194,17 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
|||||||
b.statsCollector.RecordBlocked(result)
|
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{
|
return &proxy.InterceptorResponse{
|
||||||
Action: proxy.ActionBlock,
|
Action: proxy.ActionBlock,
|
||||||
BlockCode: http.StatusForbidden,
|
BlockCode: http.StatusForbidden,
|
||||||
BlockMessage: message,
|
BlockReason: proxy.BlockReasonMalware,
|
||||||
|
BlockContext: &proxy.BlockContext{
|
||||||
|
Ecosystem: ecosystem,
|
||||||
|
PackageName: packageName,
|
||||||
|
PackageVersion: packageVersion,
|
||||||
|
MalwareSummary: result.Summary,
|
||||||
|
MalwareReferenceURL: result.ReferenceURL,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
case analyzer.ActionConfirm:
|
case analyzer.ActionConfirm:
|
||||||
@@ -227,9 +219,14 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &proxy.InterceptorResponse{
|
return &proxy.InterceptorResponse{
|
||||||
Action: proxy.ActionBlock,
|
Action: proxy.ActionBlock,
|
||||||
BlockCode: http.StatusForbidden,
|
BlockCode: http.StatusForbidden,
|
||||||
BlockMessage: fmt.Sprintf("Failed to get user confirmation for suspicious package %s/%s@%s", ecosystem.String(), packageName, packageVersion),
|
BlockReason: proxy.BlockReasonConfirmationFailed,
|
||||||
|
BlockContext: &proxy.BlockContext{
|
||||||
|
Ecosystem: ecosystem,
|
||||||
|
PackageName: packageName,
|
||||||
|
PackageVersion: packageVersion,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,16 +239,17 @@ func (b *baseRegistryInterceptor) handleAnalysisResult(
|
|||||||
b.statsCollector.RecordUserCancelled(result)
|
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{
|
return &proxy.InterceptorResponse{
|
||||||
Action: proxy.ActionBlock,
|
Action: proxy.ActionBlock,
|
||||||
BlockCode: http.StatusForbidden,
|
BlockCode: http.StatusForbidden,
|
||||||
BlockMessage: message,
|
BlockReason: proxy.BlockReasonUserDeclined,
|
||||||
|
BlockContext: &proxy.BlockContext{
|
||||||
|
Ecosystem: ecosystem,
|
||||||
|
PackageName: packageName,
|
||||||
|
PackageVersion: packageVersion,
|
||||||
|
MalwareSummary: result.Summary,
|
||||||
|
MalwareReferenceURL: result.ReferenceURL,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,15 +62,15 @@ func TestFastAllow_InsecureReturnsAllow(t *testing.T) {
|
|||||||
|
|
||||||
func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
ecosystem packagev1.Ecosystem
|
ecosystem packagev1.Ecosystem
|
||||||
packageName string
|
packageName string
|
||||||
packageVersion string
|
packageVersion string
|
||||||
analysisResult *analyzer.PackageVersionAnalysisResult
|
analysisResult *analyzer.PackageVersionAnalysisResult
|
||||||
userConfirms bool
|
userConfirms bool
|
||||||
expectedAction proxy.ResponseAction
|
expectedAction proxy.ResponseAction
|
||||||
expectedBlockCode int
|
expectedBlockCode int
|
||||||
expectBlockMessage bool
|
expectedBlockReason proxy.BlockReason
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "ActionBlock - malicious package",
|
name: "ActionBlock - malicious package",
|
||||||
@@ -82,9 +82,9 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
|||||||
Summary: "Contains known malware",
|
Summary: "Contains known malware",
|
||||||
ReferenceURL: "https://example.com/malware-report",
|
ReferenceURL: "https://example.com/malware-report",
|
||||||
},
|
},
|
||||||
expectedAction: proxy.ActionBlock,
|
expectedAction: proxy.ActionBlock,
|
||||||
expectedBlockCode: http.StatusForbidden,
|
expectedBlockCode: http.StatusForbidden,
|
||||||
expectBlockMessage: true,
|
expectedBlockReason: proxy.BlockReasonMalware,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ActionConfirm - user confirms installation",
|
name: "ActionConfirm - user confirms installation",
|
||||||
@@ -96,10 +96,10 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
|||||||
Summary: "Suspicious behavior detected",
|
Summary: "Suspicious behavior detected",
|
||||||
ReferenceURL: "https://example.com/suspicious-report",
|
ReferenceURL: "https://example.com/suspicious-report",
|
||||||
},
|
},
|
||||||
userConfirms: true,
|
userConfirms: true,
|
||||||
expectedAction: proxy.ActionAllow,
|
expectedAction: proxy.ActionAllow,
|
||||||
expectedBlockCode: 0,
|
expectedBlockCode: 0,
|
||||||
expectBlockMessage: false,
|
expectedBlockReason: proxy.BlockReasonNone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ActionConfirm - user declines installation",
|
name: "ActionConfirm - user declines installation",
|
||||||
@@ -111,10 +111,10 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
|||||||
Summary: "Suspicious behavior detected",
|
Summary: "Suspicious behavior detected",
|
||||||
ReferenceURL: "https://example.com/suspicious-report",
|
ReferenceURL: "https://example.com/suspicious-report",
|
||||||
},
|
},
|
||||||
userConfirms: false,
|
userConfirms: false,
|
||||||
expectedAction: proxy.ActionBlock,
|
expectedAction: proxy.ActionBlock,
|
||||||
expectedBlockCode: http.StatusForbidden,
|
expectedBlockCode: http.StatusForbidden,
|
||||||
expectBlockMessage: true,
|
expectedBlockReason: proxy.BlockReasonUserDeclined,
|
||||||
},
|
},
|
||||||
// Note: Timeout test case is skipped as it would require waiting 5 minutes
|
// 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
|
// 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",
|
Summary: "Package is safe",
|
||||||
ReferenceURL: "https://example.com/safe-report",
|
ReferenceURL: "https://example.com/safe-report",
|
||||||
},
|
},
|
||||||
expectedAction: proxy.ActionAllow,
|
expectedAction: proxy.ActionAllow,
|
||||||
expectedBlockCode: 0,
|
expectedBlockCode: 0,
|
||||||
expectBlockMessage: false,
|
expectedBlockReason: proxy.BlockReasonNone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ActionUnknown - default to allow",
|
name: "ActionUnknown - default to allow",
|
||||||
@@ -143,9 +143,9 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
|||||||
Summary: "Unknown action",
|
Summary: "Unknown action",
|
||||||
ReferenceURL: "https://example.com/unknown-report",
|
ReferenceURL: "https://example.com/unknown-report",
|
||||||
},
|
},
|
||||||
expectedAction: proxy.ActionAllow,
|
expectedAction: proxy.ActionAllow,
|
||||||
expectedBlockCode: 0,
|
expectedBlockCode: 0,
|
||||||
expectBlockMessage: false,
|
expectedBlockReason: proxy.BlockReasonNone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ActionBlock - pypi ecosystem",
|
name: "ActionBlock - pypi ecosystem",
|
||||||
@@ -157,9 +157,9 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
|||||||
Summary: "Malicious PyPI package",
|
Summary: "Malicious PyPI package",
|
||||||
ReferenceURL: "https://example.com/pypi-malware",
|
ReferenceURL: "https://example.com/pypi-malware",
|
||||||
},
|
},
|
||||||
expectedAction: proxy.ActionBlock,
|
expectedAction: proxy.ActionBlock,
|
||||||
expectedBlockCode: http.StatusForbidden,
|
expectedBlockCode: http.StatusForbidden,
|
||||||
expectBlockMessage: true,
|
expectedBlockReason: proxy.BlockReasonMalware,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,35 +200,25 @@ func TestBaseRegistryInterceptor_HandleAnalysisResult(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, tt.expectedAction, response.Action)
|
assert.Equal(t, tt.expectedAction, response.Action)
|
||||||
assert.Equal(t, tt.expectedBlockCode, response.BlockCode)
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -126,15 +126,18 @@ func (h *goCooldownHandler) CheckZipDownload(ctx *proxy.RequestContext, baseURL,
|
|||||||
pv.SetVersion(version)
|
pv.SetVersion(version)
|
||||||
audit.LogDependencyCooldown(pv, publishTime, cooldownDays, daysAgo, daysLeft)
|
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{
|
return &proxy.InterceptorResponse{
|
||||||
Action: proxy.ActionBlock,
|
Action: proxy.ActionBlock,
|
||||||
BlockCode: http.StatusForbidden,
|
BlockCode: http.StatusForbidden,
|
||||||
BlockMessage: message,
|
BlockReason: proxy.BlockReasonDependencyCooldown,
|
||||||
|
BlockContext: &proxy.BlockContext{
|
||||||
|
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_GO,
|
||||||
|
PackageName: module,
|
||||||
|
PackageVersion: version,
|
||||||
|
CooldownDays: cooldownDays,
|
||||||
|
CooldownDaysAgo: daysAgo,
|
||||||
|
CooldownDaysLeft: daysLeft,
|
||||||
|
},
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
"github.com/safedep/pmg/proxy"
|
"github.com/safedep/pmg/proxy"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -39,6 +40,15 @@ func TestGoCooldownCheckZipDownloadSideFetch(t *testing.T) {
|
|||||||
require.True(t, handled)
|
require.True(t, handled)
|
||||||
assert.Equal(t, proxy.ActionBlock, resp.Action)
|
assert.Equal(t, proxy.ActionBlock, resp.Action)
|
||||||
assert.Equal(t, http.StatusForbidden, resp.BlockCode)
|
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) {
|
t.Run("fails open when the out-of-band fetch fails", func(t *testing.T) {
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ type ProxyConfig struct {
|
|||||||
// Interceptors
|
// Interceptors
|
||||||
Interceptors []Interceptor
|
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
|
// Other configuration
|
||||||
EnableMITM bool
|
EnableMITM bool
|
||||||
RequestTimeout time.Duration
|
RequestTimeout time.Duration
|
||||||
@@ -561,6 +566,9 @@ func (ps *proxyServer) registerHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message := resp.BlockMessage
|
message := resp.BlockMessage
|
||||||
|
if message == "" && ps.config.BlockMessageRenderer != nil {
|
||||||
|
message = ps.config.BlockMessageRenderer(resp.BlockReason, resp.BlockContext)
|
||||||
|
}
|
||||||
if message == "" {
|
if message == "" {
|
||||||
message = "Blocked by proxy interceptor"
|
message = "Blocked by proxy interceptor"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
|
|
||||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
"github.com/safedep/pmg/analyzer"
|
"github.com/safedep/pmg/analyzer"
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/internal/models"
|
"github.com/safedep/pmg/internal/models"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
"github.com/safedep/pmg/proxy"
|
"github.com/safedep/pmg/proxy"
|
||||||
"github.com/safedep/pmg/proxy/certmanager"
|
"github.com/safedep/pmg/proxy/certmanager"
|
||||||
"github.com/safedep/pmg/proxy/interceptors"
|
"github.com/safedep/pmg/proxy/interceptors"
|
||||||
@@ -151,6 +153,8 @@ func buildProxy(t *testing.T, certMgr certmanager.CertificateManager, upstreamAd
|
|||||||
cfg := proxy.DefaultProxyConfig()
|
cfg := proxy.DefaultProxyConfig()
|
||||||
cfg.CertManager = certMgr
|
cfg.CertManager = certMgr
|
||||||
cfg.Interceptors = interceptorList
|
cfg.Interceptors = interceptorList
|
||||||
|
presenter := ui.ProxyPresenter{Advisory: config.AdvisoryMessage}
|
||||||
|
cfg.BlockMessageRenderer = presenter.BlockMessage
|
||||||
|
|
||||||
// All upstream connections — MITM'd round-trips and CONNECT tunnels for
|
// All upstream connections — MITM'd round-trips and CONNECT tunnels for
|
||||||
// non-MITM hosts alike — terminate at the mock registry, so no test reaches
|
// non-MITM hosts alike — terminate at the mock registry, so no test reaches
|
||||||
|
|||||||
Reference in New Issue
Block a user