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 <noreply@anthropic.com>
This commit is contained in:
Abhisek Datta
2026-06-01 15:32:22 +05:30
committed by GitHub
co-authored by Claude
parent 5018f8e2ff
commit f6e1d9e733
10 changed files with 331 additions and 61 deletions
+8
View File
@@ -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
}
+27
View File
@@ -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)
}
+73 -2
View File
@@ -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)
}
+121
View File
@@ -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
+15 -1
View File
@@ -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
+14 -55
View File
@@ -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
}
+63
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
+8 -1
View File
@@ -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: