From ab1a5386d345ad6417733a320cdd56c6fd97409c Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Wed, 10 Jun 2026 13:42:09 +0530 Subject: [PATCH] fix: Malysis analyzer should fallback to Community Mode when API Credentials are Incorrect (#325) --- analyzer/malysis_factory.go | 31 ++++-- analyzer/malysis_factory_test.go | 49 +++++++++ analyzer/malysis_fallback.go | 71 +++++++++++++ analyzer/malysis_fallback_test.go | 169 ++++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 analyzer/malysis_factory_test.go create mode 100644 analyzer/malysis_fallback.go create mode 100644 analyzer/malysis_fallback_test.go diff --git a/analyzer/malysis_factory.go b/analyzer/malysis_factory.go index d937f1c..2478b5b 100644 --- a/analyzer/malysis_factory.go +++ b/analyzer/malysis_factory.go @@ -1,20 +1,34 @@ package analyzer import ( + "github.com/safedep/dry/cloud" "github.com/safedep/dry/log" "github.com/safedep/pmg/internal/cloudauth" ) +type credentialsResolver func() (*cloud.Credentials, func() error, error) + // 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. +// api.safedep.io and honors tenant-specific package exclusions, degrading to +// the unauthenticated community analyzer if the API rejects the credentials. +// When no credentials are available, it returns the community analyzer. func NewMalysisAnalyzer(config MalysisQueryAnalyzerConfig) (PackageVersionAnalyzer, error) { - creds, closeResolver, err := cloudauth.ResolveCredentials() + return newMalysisAnalyzer(config, cloudauth.ResolveCredentials) +} + +func newMalysisAnalyzer(config MalysisQueryAnalyzerConfig, + resolveCredentials credentialsResolver) (PackageVersionAnalyzer, error) { + community, err := NewMalysisQueryAnalyzer(config) + if err != nil { + return nil, err + } + + creds, closeResolver, err := resolveCredentials() if err != nil { log.Debugf("SafeDep Cloud credentials unavailable, using community malysis analyzer: %v", err) - return NewMalysisQueryAnalyzer(config) + return community, nil } defer func() { if closeErr := closeResolver(); closeErr != nil { @@ -22,6 +36,11 @@ func NewMalysisAnalyzer(config MalysisQueryAnalyzerConfig) (PackageVersionAnalyz } }() - log.Debugf("SafeDep Cloud credentials found, using authenticated malysis analyzer") - return NewMalysisAuthenticatedQueryAnalyzer(config, creds) + authenticated, err := NewMalysisAuthenticatedQueryAnalyzer(config, creds) + if err != nil { + return nil, err + } + + log.Debugf("SafeDep Cloud credentials found, using authenticated malysis analyzer with community fallback") + return newMalysisFallbackAnalyzer(authenticated, community), nil } diff --git a/analyzer/malysis_factory_test.go b/analyzer/malysis_factory_test.go new file mode 100644 index 0000000..0b544dc --- /dev/null +++ b/analyzer/malysis_factory_test.go @@ -0,0 +1,49 @@ +package analyzer + +import ( + "errors" + "testing" + + "github.com/safedep/dry/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMalysisAnalyzer_CommunityWhenCredentialsUnavailable(t *testing.T) { + resolver := func() (*cloud.Credentials, func() error, error) { + return nil, func() error { return nil }, errors.New("no credentials") + } + + an, err := newMalysisAnalyzer(MalysisQueryAnalyzerConfig{}, resolver) + require.NoError(t, err) + + community, ok := an.(*malysisQueryAnalyzer) + require.True(t, ok, "must be the plain community analyzer, got %T", an) + assert.False(t, community.honorExclusions) +} + +func TestNewMalysisAnalyzer_FallbackWrappedWhenCredentialsAvailable(t *testing.T) { + creds, err := cloud.NewAPIKeyCredential("test-key", "test-tenant") + require.NoError(t, err) + + resolverClosed := false + resolver := func() (*cloud.Credentials, func() error, error) { + return creds, func() error { resolverClosed = true; return nil }, nil + } + + an, err := newMalysisAnalyzer(MalysisQueryAnalyzerConfig{}, resolver) + require.NoError(t, err) + + fb, ok := an.(*malysisFallbackAnalyzer) + require.True(t, ok, "credentialed analyzer must carry a community fallback, got %T", an) + + primary, ok := fb.primary.(*malysisQueryAnalyzer) + require.True(t, ok) + assert.True(t, primary.honorExclusions, "primary must be the authenticated analyzer") + + fallback, ok := fb.fallback.(*malysisQueryAnalyzer) + require.True(t, ok) + assert.False(t, fallback.honorExclusions, "fallback must be the community analyzer") + + assert.True(t, resolverClosed, "credential resolver must be closed after analyzer creation") +} diff --git a/analyzer/malysis_fallback.go b/analyzer/malysis_fallback.go new file mode 100644 index 0000000..666f58b --- /dev/null +++ b/analyzer/malysis_fallback.go @@ -0,0 +1,71 @@ +package analyzer + +import ( + "context" + "sync" + "sync/atomic" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/safedep/dry/log" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// malysisFallbackAnalyzer wraps an authenticated malysis analyzer and degrades +// to the community analyzer when the primary rejects our credentials. The +// failed query is retried on the fallback so a credential misconfiguration +// never drops a package verdict (fail-open on detection is not acceptable). +// Degrade is sticky for the lifetime of the analyzer: once credentials are +// rejected, all subsequent queries go to the fallback. +type malysisFallbackAnalyzer struct { + primary PackageVersionAnalyzer + fallback PackageVersionAnalyzer + + degraded atomic.Bool + degradeOnce sync.Once +} + +var _ PackageVersionAnalyzer = &malysisFallbackAnalyzer{} + +func newMalysisFallbackAnalyzer(primary, fallback PackageVersionAnalyzer) *malysisFallbackAnalyzer { + return &malysisFallbackAnalyzer{ + primary: primary, + fallback: fallback, + } +} + +func (a *malysisFallbackAnalyzer) Name() string { + if a.degraded.Load() { + return a.fallback.Name() + } + return a.primary.Name() +} + +func (a *malysisFallbackAnalyzer) Analyze(ctx context.Context, + packageVersion *packagev1.PackageVersion) (*PackageVersionAnalysisResult, error) { + if a.degraded.Load() { + return a.fallback.Analyze(ctx, packageVersion) + } + + result, err := a.primary.Analyze(ctx, packageVersion) + if err == nil || !isAuthError(err) { + return result, err + } + + a.degraded.Store(true) + a.degradeOnce.Do(func() { + log.Warnf("SafeDep Cloud credentials rejected, falling back to community malware analysis: %v", err) + }) + + return a.fallback.Analyze(ctx, packageVersion) +} + +// isAuthError reports whether err is a credential rejection from the API. +// status.FromError unwraps wrapped error chains since gRPC v1.75.0. +func isAuthError(err error) bool { + s, ok := status.FromError(err) + if !ok { + return false + } + return s.Code() == codes.Unauthenticated || s.Code() == codes.PermissionDenied +} diff --git a/analyzer/malysis_fallback_test.go b/analyzer/malysis_fallback_test.go new file mode 100644 index 0000000..acb31cd --- /dev/null +++ b/analyzer/malysis_fallback_test.go @@ -0,0 +1,169 @@ +package analyzer + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type fakePackageVersionAnalyzer struct { + calls atomic.Int64 + result *PackageVersionAnalysisResult + err error +} + +func (f *fakePackageVersionAnalyzer) Name() string { return "fake" } + +func (f *fakePackageVersionAnalyzer) Analyze(_ context.Context, + _ *packagev1.PackageVersion) (*PackageVersionAnalysisResult, error) { + f.calls.Add(1) + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +// wrapGrpcError mimics how malysisQueryAnalyzer.Analyze wraps gRPC errors +// before they reach the fallback analyzer. +func wrapGrpcError(code codes.Code) error { + return fmt.Errorf("failed to query package analysis: %w", + status.Error(code, code.String())) +} + +func TestMalysisFallbackAnalyzer_DegradesToFallbackOnAuthError(t *testing.T) { + tests := []struct { + name string + code codes.Code + }{ + {"unauthenticated", codes.Unauthenticated}, + {"permission denied", codes.PermissionDenied}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + primary := &fakePackageVersionAnalyzer{err: wrapGrpcError(tt.code)} + fallback := &fakePackageVersionAnalyzer{ + result: &PackageVersionAnalysisResult{Action: ActionAllow}, + } + + an := newMalysisFallbackAnalyzer(primary, fallback) + + result, err := an.Analyze(context.Background(), makePkgVersion("pkg", "1.0.0")) + require.NoError(t, err, "auth error must not escape, query must be retried on fallback") + assert.Equal(t, ActionAllow, result.Action) + assert.Equal(t, int64(1), primary.calls.Load()) + assert.Equal(t, int64(1), fallback.calls.Load()) + }) + } +} + +func TestMalysisFallbackAnalyzer_DegradeIsSticky(t *testing.T) { + primary := &fakePackageVersionAnalyzer{err: wrapGrpcError(codes.PermissionDenied)} + fallback := &fakePackageVersionAnalyzer{ + result: &PackageVersionAnalysisResult{Action: ActionAllow}, + } + + an := newMalysisFallbackAnalyzer(primary, fallback) + + _, err := an.Analyze(context.Background(), makePkgVersion("pkg", "1.0.0")) + require.NoError(t, err) + + _, err = an.Analyze(context.Background(), makePkgVersion("pkg", "2.0.0")) + require.NoError(t, err) + + assert.Equal(t, int64(1), primary.calls.Load(), "primary must not be queried after degrade") + assert.Equal(t, int64(2), fallback.calls.Load()) +} + +func TestMalysisFallbackAnalyzer_NonAuthErrorsPropagate(t *testing.T) { + tests := []struct { + name string + err error + }{ + {"unavailable", wrapGrpcError(codes.Unavailable)}, + {"deadline exceeded", wrapGrpcError(codes.DeadlineExceeded)}, + {"not found", wrapGrpcError(codes.NotFound)}, + {"non grpc error", errors.New("connection reset")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + primary := &fakePackageVersionAnalyzer{err: tt.err} + fallback := &fakePackageVersionAnalyzer{ + result: &PackageVersionAnalysisResult{Action: ActionAllow}, + } + + an := newMalysisFallbackAnalyzer(primary, fallback) + + _, err := an.Analyze(context.Background(), makePkgVersion("pkg", "1.0.0")) + require.Error(t, err) + assert.Equal(t, int64(0), fallback.calls.Load(), "non-auth errors must not trigger fallback") + + // Not degraded: the next query still goes to primary + _, err = an.Analyze(context.Background(), makePkgVersion("pkg", "2.0.0")) + require.Error(t, err) + assert.Equal(t, int64(2), primary.calls.Load()) + }) + } +} + +func TestMalysisFallbackAnalyzer_PrimarySuccessPassesThrough(t *testing.T) { + primary := &fakePackageVersionAnalyzer{ + result: &PackageVersionAnalysisResult{Action: ActionBlock}, + } + fallback := &fakePackageVersionAnalyzer{ + result: &PackageVersionAnalysisResult{Action: ActionAllow}, + } + + an := newMalysisFallbackAnalyzer(primary, fallback) + + result, err := an.Analyze(context.Background(), makePkgVersion("pkg", "1.0.0")) + require.NoError(t, err) + assert.Equal(t, ActionBlock, result.Action) + assert.Equal(t, int64(0), fallback.calls.Load()) +} + +func TestMalysisFallbackAnalyzer_ConcurrentAuthFailures(t *testing.T) { + primary := &fakePackageVersionAnalyzer{err: wrapGrpcError(codes.Unauthenticated)} + fallback := &fakePackageVersionAnalyzer{ + result: &PackageVersionAnalysisResult{Action: ActionAllow}, + } + + an := newMalysisFallbackAnalyzer(primary, fallback) + + const workers = 16 + var wg sync.WaitGroup + errs := make([]error, workers) + + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, errs[i] = an.Analyze(context.Background(), makePkgVersion("pkg", "1.0.0")) + }(i) + } + wg.Wait() + + for i, err := range errs { + assert.NoError(t, err, "worker %d must get a fallback verdict", i) + } + assert.Equal(t, int64(workers), fallback.calls.Load(), + "every in-flight auth failure must be retried on fallback") +} + +func TestMalysisFallbackAnalyzer_Name(t *testing.T) { + primary := &fakePackageVersionAnalyzer{} + fallback := &fakePackageVersionAnalyzer{} + + an := newMalysisFallbackAnalyzer(primary, fallback) + assert.Equal(t, "fake", an.Name()) +}