From f6e1d9e733ad7c56780b248883917ce28c9204c8 Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Mon, 1 Jun 2026 15:32:22 +0530 Subject: [PATCH] feat: authenticated Malysis analyzer with tenant exclusion support (#313) * feat: authenticated Malysis analyzer with tenant exclusion support When SafeDep Cloud credentials are available (keychain or environment), PMG now uses an authenticated malware analysis query against api.safedep.io instead of the unauthenticated community endpoint (community-api.safedep.io). The API key and tenant ID are supplied via the gRPC connection. The authenticated response may carry a tenant-specific malicious package exclusion. This is honored as an opt-in trust signal: a flagged package is downgraded to allow only when a concrete exclusion (non-empty ID) is present for the exact package version queried. Exclusions are never honored for community queries and never weaken the verdict for packages that were not flagged. Allowed-by-exclusion packages are surfaced as a warning so the trust decision is never silent. Changes are additive; non-authenticated usage is unchanged. Credential resolution is extracted into internal/cloudauth and reused by both the analyzer factory and the existing cloud sync client. * fix: surface tenant exclusions in proxy mode; clarify comments - Warn when proxy interceptor allows a flagged package due to a tenant exclusion, matching the guard flow so the trust decision is not silent. - Remove stray doc comment above warnIfExcluded. - Clarify that a verified-malware verdict can be downgraded by an exclusion in applyExclusion. --------- Co-authored-by: Claude --- analyzer/analyzer.go | 8 ++ analyzer/malysis_factory.go | 27 +++++++ analyzer/malysis_query.go | 75 ++++++++++++++++- analyzer/malysis_query_test.go | 121 ++++++++++++++++++++++++++++ guard/guard.go | 16 +++- internal/audit/cloud_client.go | 69 ++++------------ internal/cloudauth/resolver.go | 63 +++++++++++++++ internal/flows/common_flow.go | 2 +- internal/flows/proxy_flow.go | 2 +- proxy/interceptors/base_registry.go | 9 ++- 10 files changed, 331 insertions(+), 61 deletions(-) create mode 100644 analyzer/malysis_factory.go create mode 100644 internal/cloudauth/resolver.go diff --git a/analyzer/analyzer.go b/analyzer/analyzer.go index 7682765..aa158f3 100644 --- a/analyzer/analyzer.go +++ b/analyzer/analyzer.go @@ -41,6 +41,14 @@ type PackageVersionAnalysisResult struct { // Whether the malware verdict has been verified (confirmed by a human or verification system) IsVerified bool + // Whether a tenant-specific exclusion caused this package to be trusted + // despite a malware verdict. Only set by authenticated analyzers. + IsExcluded bool + + // The tenant-specific exclusion that trusted this package, when IsExcluded is true + ExclusionID string + ExclusionReason string + // Analyzer specific data Data any } diff --git a/analyzer/malysis_factory.go b/analyzer/malysis_factory.go new file mode 100644 index 0000000..d937f1c --- /dev/null +++ b/analyzer/malysis_factory.go @@ -0,0 +1,27 @@ +package analyzer + +import ( + "github.com/safedep/dry/log" + "github.com/safedep/pmg/internal/cloudauth" +) + +// NewMalysisAnalyzer creates the malysis query analyzer best suited for the +// current environment. When SafeDep Cloud credentials are available (via +// keychain or environment), it returns an authenticated analyzer that queries +// api.safedep.io and honors tenant-specific package exclusions. Otherwise it +// falls back to the unauthenticated community analyzer. +func NewMalysisAnalyzer(config MalysisQueryAnalyzerConfig) (PackageVersionAnalyzer, error) { + creds, closeResolver, err := cloudauth.ResolveCredentials() + if err != nil { + log.Debugf("SafeDep Cloud credentials unavailable, using community malysis analyzer: %v", err) + return NewMalysisQueryAnalyzer(config) + } + defer func() { + if closeErr := closeResolver(); closeErr != nil { + log.Warnf("failed to close credential resolver: %v", closeErr) + } + }() + + log.Debugf("SafeDep Cloud credentials found, using authenticated malysis analyzer") + return NewMalysisAuthenticatedQueryAnalyzer(config, creds) +} diff --git a/analyzer/malysis_query.go b/analyzer/malysis_query.go index b160bef..2f1cbaa 100644 --- a/analyzer/malysis_query.go +++ b/analyzer/malysis_query.go @@ -10,23 +10,37 @@ import ( packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" malysisv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/malysis/v1" drygrpc "github.com/safedep/dry/adapters/grpc" + "github.com/safedep/dry/cloud" + "github.com/safedep/dry/log" "github.com/safedep/pmg/config" "google.golang.org/grpc" ) +const ( + communityMalysisHost = "community-api.safedep.io" + communityMalysisPort = "443" +) + type MalysisQueryAnalyzerConfig struct{} type malysisQueryAnalyzer struct { client malysisv1grpc.MalwareAnalysisServiceClient Config MalysisQueryAnalyzerConfig + + // honorExclusions enables honoring tenant-specific malicious package + // exclusions returned by authenticated queries. Exclusions are never + // returned for unauthenticated (community) queries. + honorExclusions bool } var _ Analyzer = &malysisQueryAnalyzer{} var _ PackageVersionAnalyzer = &malysisQueryAnalyzer{} +// NewMalysisQueryAnalyzer creates an unauthenticated analyzer that queries the +// SafeDep community malware analysis service. func NewMalysisQueryAnalyzer(config MalysisQueryAnalyzerConfig) (*malysisQueryAnalyzer, error) { client, err := drygrpc.GrpcClient("pmg-malysis-query", - "community-api.safedep.io", "443", "", http.Header{}, []grpc.DialOption{}) + communityMalysisHost, communityMalysisPort, "", http.Header{}, []grpc.DialOption{}) if err != nil { return nil, fmt.Errorf("failed to create gRPC client: %w", err) } @@ -37,6 +51,25 @@ func NewMalysisQueryAnalyzer(config MalysisQueryAnalyzerConfig) (*malysisQueryAn }, nil } +// NewMalysisAuthenticatedQueryAnalyzer creates an analyzer that queries the +// authenticated SafeDep Cloud malware analysis service (api.safedep.io) using +// the provided API key credentials. The analysis behavior is identical to the +// community analyzer except that it additionally honors tenant-specific +// malicious package exclusions returned in the response. +func NewMalysisAuthenticatedQueryAnalyzer(config MalysisQueryAnalyzerConfig, + creds *cloud.Credentials) (*malysisQueryAnalyzer, error) { + cloudClient, err := cloud.NewDataPlaneClient("pmg-malysis-query", creds) + if err != nil { + return nil, fmt.Errorf("failed to create authenticated gRPC client: %w", err) + } + + return &malysisQueryAnalyzer{ + client: malysisv1grpc.NewMalwareAnalysisServiceClient(cloudClient.Connection()), + Config: config, + honorExclusions: true, + }, nil +} + func (a *malysisQueryAnalyzer) Name() string { return "malysis-query" } @@ -75,16 +108,54 @@ func (a *malysisQueryAnalyzer) Analyze(ctx context.Context, } } - // This is a confirmed malicious package, we must always block it + // A confirmed malicious package is blocked here, unless a tenant exclusion + // downgrades it to allow in applyExclusion below. if res.GetVerificationRecord().GetIsMalware() { analysisResult.IsMalware = true analysisResult.IsVerified = true analysisResult.Action = ActionBlock } + // Honor tenant-specific exclusion as an opt-in trust signal. This is only + // applied for authenticated queries and only when the package was actually + // flagged. The exclusion in the response is scoped by the server to the + // exact package version we queried, so it is an exact match by construction. + a.applyExclusion(analysisResult, res) + return analysisResult, nil } +// applyExclusion downgrades a flagged package to ActionAllow when the +// authenticated response carries a tenant-specific malicious package exclusion. +// The exclusion is honored only when the package was flagged as malware, so it +// never weakens the verdict for packages that were already allowed. +func (a *malysisQueryAnalyzer) applyExclusion(result *PackageVersionAnalysisResult, + res *malysisv1.QueryPackageAnalysisResponse) { + if !a.honorExclusions { + return + } + + exclusion := res.GetMaliciousPackageExclusion() + if exclusion == nil || exclusion.GetExclusionId() == "" { + return + } + + if !result.IsMalware { + return + } + + log.Debugf("Honoring tenant exclusion %q for package %s@%s: %s", + exclusion.GetExclusionId(), + result.PackageVersion.GetPackage().GetName(), + result.PackageVersion.GetVersion(), + exclusion.GetReason()) + + result.IsExcluded = true + result.ExclusionID = exclusion.GetExclusionId() + result.ExclusionReason = exclusion.GetReason() + result.Action = ActionAllow +} + func malysisReportUrl(analysisId string) string { return fmt.Sprintf("https://app.safedep.io/community/malysis/%s", analysisId) } diff --git a/analyzer/malysis_query_test.go b/analyzer/malysis_query_test.go index 4805c6f..3406ebc 100644 --- a/analyzer/malysis_query_test.go +++ b/analyzer/malysis_query_test.go @@ -150,6 +150,127 @@ func TestMalysisQueryAnalyzer_AlwaysBlockOnVerifiedMalware(t *testing.T) { assert.Equal(t, ActionBlock, result.Action, "Verified malware must be blocked always") } +func TestMalysisQueryAnalyzer_HonorsExclusionForFlaggedPackage(t *testing.T) { + cfg := config.Get() + origParanoid := cfg.Config.Paranoid + cfg.Config.Paranoid = false + defer func() { cfg.Config.Paranoid = origParanoid }() + + // Verified malware that would normally be blocked, but the tenant has an + // exclusion trusting this exact package version. + resp := &malysisv1.QueryPackageAnalysisResponse{ + AnalysisId: "analysis-excl", + Report: &malysisv1pb.Report{ + Inference: &malysisv1pb.Report_Inference{ + IsMalware: true, + Summary: "Suspicious patterns detected", + }, + }, + VerificationRecord: &malysisv1pb.VerificationRecord{ + IsMalware: true, + }, + MaliciousPackageExclusion: &malysisv1.QueryPackageAnalysisResponse_MaliciousPackageExclusion{ + ExclusionId: "excl-1", + Reason: "Reviewed and trusted internally", + }, + } + + an := &malysisQueryAnalyzer{ + client: &stubMalwareAnalysisServiceClient{resp: resp}, + honorExclusions: true, + } + + pv := makePkgVersion("trusted-internal-pkg", "1.2.3") + result, err := an.Analyze(context.Background(), pv) + assert.NoError(t, err) + assert.Equal(t, ActionAllow, result.Action, "Exclusion should downgrade a flagged package to allow") + assert.True(t, result.IsExcluded) + assert.Equal(t, "excl-1", result.ExclusionID) + assert.Equal(t, "Reviewed and trusted internally", result.ExclusionReason) + // Inference flags are retained for reporting/audit + assert.True(t, result.IsMalware) + assert.True(t, result.IsVerified) +} + +func TestMalysisQueryAnalyzer_IgnoresExclusionWhenNotEnabled(t *testing.T) { + // Community analyzer must never honor exclusions even if one is present. + resp := &malysisv1.QueryPackageAnalysisResponse{ + AnalysisId: "analysis-excl-2", + Report: &malysisv1pb.Report{ + Inference: &malysisv1pb.Report_Inference{IsMalware: true}, + }, + VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: true}, + MaliciousPackageExclusion: &malysisv1.QueryPackageAnalysisResponse_MaliciousPackageExclusion{ + ExclusionId: "excl-2", + Reason: "Should be ignored", + }, + } + + an := &malysisQueryAnalyzer{ + client: &stubMalwareAnalysisServiceClient{resp: resp}, + honorExclusions: false, + } + + pv := makePkgVersion("verified-malware", "9.9.9") + result, err := an.Analyze(context.Background(), pv) + assert.NoError(t, err) + assert.Equal(t, ActionBlock, result.Action, "Exclusions must be ignored when not enabled") + assert.False(t, result.IsExcluded) +} + +func TestMalysisQueryAnalyzer_IgnoresEmptyExclusionId(t *testing.T) { + // An exclusion with no ID is not a concrete, exact match and must be ignored. + resp := &malysisv1.QueryPackageAnalysisResponse{ + AnalysisId: "analysis-excl-3", + Report: &malysisv1pb.Report{ + Inference: &malysisv1pb.Report_Inference{IsMalware: true}, + }, + VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: true}, + MaliciousPackageExclusion: &malysisv1.QueryPackageAnalysisResponse_MaliciousPackageExclusion{ + ExclusionId: "", + Reason: "No id", + }, + } + + an := &malysisQueryAnalyzer{ + client: &stubMalwareAnalysisServiceClient{resp: resp}, + honorExclusions: true, + } + + pv := makePkgVersion("verified-malware", "9.9.9") + result, err := an.Analyze(context.Background(), pv) + assert.NoError(t, err) + assert.Equal(t, ActionBlock, result.Action) + assert.False(t, result.IsExcluded) +} + +func TestMalysisQueryAnalyzer_ExclusionDoesNotAffectCleanPackage(t *testing.T) { + // A non-malware package with a spurious exclusion stays allowed and is not + // marked as excluded (nothing to trust). + resp := &malysisv1.QueryPackageAnalysisResponse{ + AnalysisId: "analysis-excl-4", + Report: &malysisv1pb.Report{ + Inference: &malysisv1pb.Report_Inference{IsMalware: false}, + }, + VerificationRecord: &malysisv1pb.VerificationRecord{IsMalware: false}, + MaliciousPackageExclusion: &malysisv1.QueryPackageAnalysisResponse_MaliciousPackageExclusion{ + ExclusionId: "excl-4", + Reason: "Stale exclusion", + }, + } + + an := &malysisQueryAnalyzer{ + client: &stubMalwareAnalysisServiceClient{resp: resp}, + honorExclusions: true, + } + + pv := makePkgVersion("clean-pkg", "1.0.0") + result, err := an.Analyze(context.Background(), pv) + assert.NoError(t, err) + assert.Equal(t, ActionAllow, result.Action) + assert.False(t, result.IsExcluded) +} + // Implement the full client interface surface expected by malysisv1grpc.MalwareAnalysisServiceClient func (s *stubMalwareAnalysisServiceClient) AnalyzePackage(ctx context.Context, req *malysisv1.AnalyzePackageRequest, opts ...grpc.CallOption) (*malysisv1.AnalyzePackageResponse, error) { // Not used in these tests; return a nil response with no error diff --git a/guard/guard.go b/guard/guard.go index c676ac3..34fa9e7 100644 --- a/guard/guard.go +++ b/guard/guard.go @@ -215,6 +215,7 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm confirmableMalwarePackages = append(confirmableMalwarePackages, analysisResult) } else { result.AllowedCount++ + g.warnIfExcluded(analysisResult) } } @@ -453,6 +454,7 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa confirmableMalwarePackages = append(confirmableMalwarePackages, analysisResult) } else { result.AllowedCount++ + g.warnIfExcluded(analysisResult) } } @@ -497,7 +499,19 @@ func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, pa return result, g.continueExecution(ctx, parsedCommand) } -// logMalwareDetection logs malware detection events +// warnIfExcluded surfaces a security-relevant notice when a flagged package was +// allowed only because of a tenant-specific exclusion, so it is never silently +// trusted. +func (g *packageManagerGuard) warnIfExcluded(result *analyzer.PackageVersionAnalysisResult) { + if result == nil || !result.IsExcluded || result.PackageVersion == nil { + return + } + + pkg := result.PackageVersion + g.showWarning(fmt.Sprintf("Allowing flagged package %s@%s due to tenant exclusion (%s)", + pkg.GetPackage().GetName(), pkg.GetVersion(), result.ExclusionReason)) +} + func (g *packageManagerGuard) logMalwareDetection(result *analyzer.PackageVersionAnalysisResult, blocked bool) { if result == nil || result.PackageVersion == nil { return diff --git a/internal/audit/cloud_client.go b/internal/audit/cloud_client.go index bf919af..2de3060 100644 --- a/internal/audit/cloud_client.go +++ b/internal/audit/cloud_client.go @@ -9,15 +9,15 @@ import ( "github.com/safedep/dry/cloud/endpointsync" "github.com/safedep/dry/log" "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/cloudauth" appVersion "github.com/safedep/pmg/internal/version" ) // SyncClientBundle holds a SyncClient and its underlying cloud client. // Callers must call Close() when done. type SyncClientBundle struct { - syncClient *endpointsync.SyncClient - cloudClient *cloud.Client - keychainResolver cloud.CloseableCredentialResolver + syncClient *endpointsync.SyncClient + cloudClient *cloud.Client } // Sync delivers pending events from the WAL to SafeDep Cloud. @@ -37,61 +37,26 @@ func (b *SyncClientBundle) Close() error { errs = append(errs, err) } } - if b.keychainResolver != nil { - if err := b.keychainResolver.Close(); err != nil { - errs = append(errs, err) - } - } return errors.Join(errs...) } // NewSyncClientBundle creates an authenticated SyncClient connected to SafeDep Cloud. func NewSyncClientBundle(cfg *config.RuntimeConfig) (*SyncClientBundle, error) { - // Build credential resolver chain: keychain first, env fallback. - var resolvers []cloud.CredentialResolver - var keychainResolver cloud.CloseableCredentialResolver - - keychainResolver, err := cloud.NewKeychainCredentialResolver(cloud.CredentialTypeAPIKey) + // Resolve credentials via keychain-first, env fallback chain. The keychain + // resolver can be closed immediately because the data plane client extracts + // the credential values when it is created. + creds, closeResolver, err := cloudauth.ResolveCredentials() if err != nil { - log.Debugf("Keychain credential resolver not available, skipping: %v", err) - } else { - resolvers = append(resolvers, keychainResolver) + return nil, err } - - envResolver, err := cloud.NewEnvCredentialResolver() - if err != nil { - log.Debugf("Env credential resolver not available, skipping: %v", err) - } else { - resolvers = append(resolvers, envResolver) - } - - if len(resolvers) == 0 { - if keychainResolver != nil { - if closeErr := keychainResolver.Close(); closeErr != nil { - log.Warnf("failed to close keychain resolver: %v", closeErr) - } + defer func() { + if closeErr := closeResolver(); closeErr != nil { + log.Warnf("failed to close keychain resolver: %v", closeErr) } - return nil, fmt.Errorf("no credential resolvers available") - } - - chain := cloud.NewChainCredentialResolver(resolvers...) - creds, err := chain.Resolve() - if err != nil { - if keychainResolver != nil { - if closeErr := keychainResolver.Close(); closeErr != nil { - log.Warnf("failed to close keychain resolver: %v", closeErr) - } - } - return nil, fmt.Errorf("failed to resolve cloud credentials: %w", err) - } + }() cloudClient, err := cloud.NewDataPlaneClient("pmg", creds) if err != nil { - if keychainResolver != nil { - if closeErr := keychainResolver.Close(); closeErr != nil { - log.Warnf("failed to close keychain resolver: %v", closeErr) - } - } return nil, fmt.Errorf("failed to create data plane client: %w", err) } @@ -115,17 +80,11 @@ func NewSyncClientBundle(cfg *config.RuntimeConfig) (*SyncClientBundle, error) { if closeErr := cloudClient.Close(); closeErr != nil { log.Warnf("failed to close cloud client after sync client init failure: %v", closeErr) } - if keychainResolver != nil { - if closeErr := keychainResolver.Close(); closeErr != nil { - log.Warnf("failed to close keychain resolver: %v", closeErr) - } - } return nil, fmt.Errorf("failed to create sync client: %w", err) } return &SyncClientBundle{ - syncClient: syncClient, - cloudClient: cloudClient, - keychainResolver: keychainResolver, + syncClient: syncClient, + cloudClient: cloudClient, }, nil } diff --git a/internal/cloudauth/resolver.go b/internal/cloudauth/resolver.go new file mode 100644 index 0000000..60d8f09 --- /dev/null +++ b/internal/cloudauth/resolver.go @@ -0,0 +1,63 @@ +// Package cloudauth provides shared helpers for resolving SafeDep Cloud +// credentials from the local keychain or environment. It centralizes the +// credential resolver chain used by both audit sync and authenticated +// analyzers so the resolution behavior stays consistent. +package cloudauth + +import ( + "fmt" + + "github.com/safedep/dry/cloud" + "github.com/safedep/dry/log" +) + +// ResolveCredentials resolves SafeDep Cloud API key credentials using a +// keychain-first, environment-fallback chain. It returns the resolved +// credentials and a close function that releases keychain resources. The +// close function is always non-nil and safe to call regardless of the error. +// +// An error is returned when no credentials are available, which callers can +// use to decide whether authenticated cloud features should be enabled. +func ResolveCredentials() (*cloud.Credentials, func() error, error) { + var resolvers []cloud.CredentialResolver + var keychainResolver cloud.CloseableCredentialResolver + + keychainResolver, err := cloud.NewKeychainCredentialResolver(cloud.CredentialTypeAPIKey) + if err != nil { + log.Debugf("Keychain credential resolver not available, skipping: %v", err) + } else { + resolvers = append(resolvers, keychainResolver) + } + + envResolver, err := cloud.NewEnvCredentialResolver() + if err != nil { + log.Debugf("Env credential resolver not available, skipping: %v", err) + } else { + resolvers = append(resolvers, envResolver) + } + + closeFn := func() error { + if keychainResolver != nil { + return keychainResolver.Close() + } + return nil + } + + if len(resolvers) == 0 { + if err := closeFn(); err != nil { + log.Warnf("failed to close keychain resolver: %v", err) + } + return nil, func() error { return nil }, fmt.Errorf("no credential resolvers available") + } + + chain := cloud.NewChainCredentialResolver(resolvers...) + creds, err := chain.Resolve() + if err != nil { + if closeErr := closeFn(); closeErr != nil { + log.Warnf("failed to close keychain resolver: %v", closeErr) + } + return nil, func() error { return nil }, fmt.Errorf("failed to resolve cloud credentials: %w", err) + } + + return creds, closeFn, nil +} diff --git a/internal/flows/common_flow.go b/internal/flows/common_flow.go index 9dc413d..40d81e0 100644 --- a/internal/flows/common_flow.go +++ b/internal/flows/common_flow.go @@ -59,7 +59,7 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem startTime := time.Now() - malysisQueryAnalyzer, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) + malysisQueryAnalyzer, err := analyzer.NewMalysisAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) if err != nil { return fmt.Errorf("failed to create malware analyzer: %w", err) } diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 3089134..ad56376 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -340,7 +340,7 @@ func (f *proxyFlow) createCertificateManager(caCert *certmanager.Certificate) (c // createAnalyzer creates the malysis query analyzer func (f *proxyFlow) createAnalyzer() (analyzer.PackageVersionAnalyzer, error) { log.Debugf("Creating malysis query analyzer") - return analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) + return analyzer.NewMalysisAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) } // createAndStartProxyServer creates and starts the proxy server with the given interceptor diff --git a/proxy/interceptors/base_registry.go b/proxy/interceptors/base_registry.go index cf16f65..9dc1b1a 100644 --- a/proxy/interceptors/base_registry.go +++ b/proxy/interceptors/base_registry.go @@ -229,7 +229,14 @@ func (b *baseRegistryInterceptor) handleAnalysisResult( b.statsCollector.RecordAllowed(result) } - log.Debugf("[%s] Package %s/%s@%s is safe, allowing request", ctx.RequestID, ecosystem.String(), packageName, packageVersion) + // A flagged package allowed only because of a tenant-specific exclusion is + // a security-relevant trust decision; surface it so it is never silent. + if result.IsExcluded { + log.Warnf("[%s] Allowing flagged package %s/%s@%s due to tenant exclusion (%s)", + ctx.RequestID, ecosystem.String(), packageName, packageVersion, result.ExclusionReason) + } else { + log.Debugf("[%s] Package %s/%s@%s is safe, allowing request", ctx.RequestID, ecosystem.String(), packageName, packageVersion) + } return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil default: