mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: Malysis analyzer should fallback to Community Mode when API Credentials are Incorrect (#325)
This commit is contained in:
@@ -1,20 +1,34 @@
|
|||||||
package analyzer
|
package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/safedep/dry/cloud"
|
||||||
"github.com/safedep/dry/log"
|
"github.com/safedep/dry/log"
|
||||||
"github.com/safedep/pmg/internal/cloudauth"
|
"github.com/safedep/pmg/internal/cloudauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type credentialsResolver func() (*cloud.Credentials, func() error, error)
|
||||||
|
|
||||||
// NewMalysisAnalyzer creates the malysis query analyzer best suited for the
|
// NewMalysisAnalyzer creates the malysis query analyzer best suited for the
|
||||||
// current environment. When SafeDep Cloud credentials are available (via
|
// current environment. When SafeDep Cloud credentials are available (via
|
||||||
// keychain or environment), it returns an authenticated analyzer that queries
|
// keychain or environment), it returns an authenticated analyzer that queries
|
||||||
// api.safedep.io and honors tenant-specific package exclusions. Otherwise it
|
// api.safedep.io and honors tenant-specific package exclusions, degrading to
|
||||||
// falls back to the unauthenticated community analyzer.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
log.Debugf("SafeDep Cloud credentials unavailable, using community malysis analyzer: %v", err)
|
log.Debugf("SafeDep Cloud credentials unavailable, using community malysis analyzer: %v", err)
|
||||||
return NewMalysisQueryAnalyzer(config)
|
return community, nil
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if closeErr := closeResolver(); closeErr != nil {
|
if closeErr := closeResolver(); closeErr != nil {
|
||||||
@@ -22,6 +36,11 @@ func NewMalysisAnalyzer(config MalysisQueryAnalyzerConfig) (PackageVersionAnalyz
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Debugf("SafeDep Cloud credentials found, using authenticated malysis analyzer")
|
authenticated, err := NewMalysisAuthenticatedQueryAnalyzer(config, creds)
|
||||||
return 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user