From cd0f9abe9ead087ed33a12f62daf67734e414ef1 Mon Sep 17 00:00:00 2001 From: Matt Brock Date: Thu, 12 Dec 2024 12:59:11 -0800 Subject: [PATCH] Oak/policy hackathon mbrock changes (#50169) * Adding access analyzer to create user tasks * Pulling new policy recommendation and comparing against the old one * Upserting the policy updates as a user task * Decoding existing doc and remove user task upserting * Now sending policy recommendations to access graph --- go.mod | 2 + go.sum | 2 + lib/cloud/clients.go | 21 +++ lib/srv/discovery/access_graph.go | 1 + .../fetchers/aws-sync/access_analyzer.go | 124 ++++++++++++++++++ .../discovery/fetchers/aws-sync/aws-sync.go | 12 ++ lib/srv/discovery/fetchers/aws-sync/merge.go | 2 + .../discovery/fetchers/aws-sync/reconcile.go | 9 ++ 8 files changed, 173 insertions(+) create mode 100644 lib/srv/discovery/fetchers/aws-sync/access_analyzer.go diff --git a/go.mod b/go.mod index 643ffe1e06d5c..346e452e58145 100644 --- a/go.mod +++ b/go.mod @@ -238,6 +238,8 @@ require ( software.sslmate.com/src/go-pkcs12 v0.5.0 ) +require github.com/aws/aws-sdk-go-v2/service/accessanalyzer v1.36.2 + require ( cel.dev/expr v0.16.1 // indirect cloud.google.com/go v0.116.0 // indirect diff --git a/go.sum b/go.sum index 470c2c0a87f07..2d3fffd378970 100644 --- a/go.sum +++ b/go.sum @@ -879,6 +879,8 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvK github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE= +github.com/aws/aws-sdk-go-v2/service/accessanalyzer v1.36.2 h1:9WvCTutkgDExBamb9UZQ94oiCjJwXUhhtoBH3NIs0Iw= +github.com/aws/aws-sdk-go-v2/service/accessanalyzer v1.36.2/go.mod h1:9QmJU2Zam+wUZe8etjM4VY9NlC0WeMFLIvtUIOIko4U= github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.34.1 h1:8EwNbY+A/Q5myYggLJ7v9v9f00UuWoh9S04y5kre8UQ= github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.34.1/go.mod h1:2mMP2R86zLPAUz0TpJdsKW8XawHgs9Nk97fYJomO3o8= github.com/aws/aws-sdk-go-v2/service/athena v1.49.0 h1:D+iatX9gV6gCuNd6BnUkfwfZJw/cXlEk+LwwDdSMdtw= diff --git a/lib/cloud/clients.go b/lib/cloud/clients.go index ac21c4c89c045..c1099eec636e5 100644 --- a/lib/cloud/clients.go +++ b/lib/cloud/clients.go @@ -38,6 +38,8 @@ import ( "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/aws/aws-sdk-go/aws/request" awssession "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/accessanalyzer" + "github.com/aws/aws-sdk-go/service/accessanalyzer/accessanalyzeriface" "github.com/aws/aws-sdk-go/service/eks" "github.com/aws/aws-sdk-go/service/eks/eksiface" "github.com/aws/aws-sdk-go/service/elasticache" @@ -137,6 +139,8 @@ type AWSClients interface { GetAWSKMSClient(ctx context.Context, region string, opts ...AWSOptionsFn) (kmsiface.KMSAPI, error) // GetAWSS3Client returns AWS S3 client. GetAWSS3Client(ctx context.Context, region string, opts ...AWSOptionsFn) (s3iface.S3API, error) + // GetAWSIAMAccessAnalyzerClient returns AWS IAM Access Analyzer client + GetAWSIAMAccessAnalyzerClient(ctx context.Context, region string, opts ...AWSOptionsFn) (accessanalyzeriface.AccessAnalyzerAPI, error) } // AzureClients is an interface for Azure-specific API clients @@ -597,6 +601,14 @@ func (c *cloudClients) GetAWSEKSClient(ctx context.Context, region string, opts return eks.New(session), nil } +func (c *cloudClients) GetAWSIAMAccessAnalyzerClient(ctx context.Context, region string, opts ...AWSOptionsFn) (accessanalyzeriface.AccessAnalyzerAPI, error) { + session, err := c.GetAWSSession(ctx, region, opts...) + if err != nil { + return nil, trace.Wrap(err) + } + return accessanalyzer.New(session), nil +} + // GetAWSKMSClient returns AWS KMS client for the specified region. func (c *cloudClients) GetAWSKMSClient(ctx context.Context, region string, opts ...AWSOptionsFn) (kmsiface.KMSAPI, error) { session, err := c.GetAWSSession(ctx, region, opts...) @@ -1012,6 +1024,7 @@ type TestCloudClients struct { EKS eksiface.EKSAPI KMS kmsiface.KMSAPI S3 s3iface.S3API + AccessAnalyzer accessanalyzeriface.AccessAnalyzerAPI AzureMySQL azure.DBServersClient AzureMySQLPerSub map[string]azure.DBServersClient AzurePostgres azure.DBServersClient @@ -1177,6 +1190,14 @@ func (c *TestCloudClients) GetAWSKMSClient(ctx context.Context, region string, o return c.KMS, nil } +func (c *TestCloudClients) GetAWSIAMAccessAnalyzerClient(ctx context.Context, region string, opts ...AWSOptionsFn) (accessanalyzeriface.AccessAnalyzerAPI, error) { + _, err := c.GetAWSSession(ctx, region, opts...) + if err != nil { + return nil, trace.Wrap(err) + } + return c.AccessAnalyzer, nil +} + // GetGCPIAMClient returns GCP IAM client. func (c *TestCloudClients) GetGCPIAMClient(ctx context.Context) (*gcpcredentials.IamCredentialsClient, error) { return gcpcredentials.NewIamCredentialsClient(ctx, diff --git a/lib/srv/discovery/access_graph.go b/lib/srv/discovery/access_graph.go index dbbdf508dd94a..81ab294c68f9d 100644 --- a/lib/srv/discovery/access_graph.go +++ b/lib/srv/discovery/access_graph.go @@ -509,6 +509,7 @@ func (s *Server) accessGraphFetchersFromMatchers(ctx context.Context, matchers M Regions: awsFetcher.Regions, Integration: awsFetcher.Integration, DiscoveryConfigName: discoveryConfigName, + AccessPoint: s.AccessPoint, }, ) if err != nil { diff --git a/lib/srv/discovery/fetchers/aws-sync/access_analyzer.go b/lib/srv/discovery/fetchers/aws-sync/access_analyzer.go new file mode 100644 index 0000000000000..2b7d4cce0e42d --- /dev/null +++ b/lib/srv/discovery/fetchers/aws-sync/access_analyzer.go @@ -0,0 +1,124 @@ +package aws_sync + +import ( + "context" + "fmt" + accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha" + "net/url" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/accessanalyzer" +) + +const pollInterval = 100 * time.Millisecond +const maxPollTime = 1 * time.Minute + +func (a *awsFetcher) fetchPolicyChanges(ctx context.Context, result *Resources) ([]*accessgraphv1alpha.AWSPolicyChange, error) { + // Initialize the client + client, err := a.CloudClients.GetAWSIAMAccessAnalyzerClient(ctx, "us-east-2", a.getAWSOptions()...) + if err != nil { + return nil, err + } + analyzerArn := "arn:aws:access-analyzer:us-east-2:278576220453:analyzer/mbrock-test" + input := &accessanalyzer.ListFindingsV2Input{ + AnalyzerArn: &analyzerArn, + } + findings, err := client.ListFindingsV2(input) + if err != nil { + return nil, err + } + + // Generate the finding recommendations + var findingsWithRecs []*accessanalyzer.FindingSummaryV2 + for _, finding := range findings.Findings { + if *finding.FindingType != "UnusedPermission" || *finding.Status == "RESOLVED" { + continue + } + _, err = client.GenerateFindingRecommendation(&accessanalyzer.GenerateFindingRecommendationInput{ + AnalyzerArn: aws.String(analyzerArn), + Id: finding.Id, + }) + if err != nil { + continue + } + findingsWithRecs = append(findingsWithRecs, finding) + } + + // Poll the recommendations until they've been successfully generated or max time is reached + findingRecs := make(map[string]*accessanalyzer.GetFindingRecommendationOutput) + timeStart := time.Now() + for { + for _, finding := range findingsWithRecs { + if _, ok := findingRecs[*finding.Id]; ok { + continue + } + rec, err := client.GetFindingRecommendation(&accessanalyzer.GetFindingRecommendationInput{ + AnalyzerArn: aws.String(analyzerArn), + Id: finding.Id, + }) + if err != nil { + time.Sleep(pollInterval) + continue + } + if *rec.Status == "SUCCEEDED" { + findingRecs[*finding.Id] = rec + } else { + time.Sleep(pollInterval) + } + } + if time.Since(timeStart) > maxPollTime { + break + } + if len(findingRecs) == len(findingsWithRecs) { + break + } + } + + // Get the fetched policies and associate them with the recommendations + policies := make(map[string]*accessgraphv1alpha.AWSPolicyV1) + for _, policy := range result.Policies { + policies[policy.Arn] = policy + } + var policyChanges []*accessgraphv1alpha.AWSPolicyChange + for _, rec := range findingRecs { + policyChange := &accessgraphv1alpha.AWSPolicyChange{ + ResourceArn: *rec.ResourceArn, + } + for _, step := range rec.RecommendedSteps { + unused := step.UnusedPermissionsRecommendedStep + existingPolicy, ok := policies[*unused.ExistingPolicyId] + if !ok { + fmt.Printf("No existing policy found for %s\n", *unused.ExistingPolicyId) + continue + } + existingDoc, err := url.QueryUnescape(string(existingPolicy.PolicyDocument)) + if err != nil { + fmt.Printf("Could not decode URL-encoded policy document: %v\n", err) + continue + } + newDoc := "" + if unused.RecommendedPolicy != nil { + newDoc = *unused.RecommendedPolicy + } + fmt.Printf("Recommending policy change from '%s' to '%s'\n", existingDoc, newDoc) + change := accessgraphv1alpha.PolicyChange{ + PolicyName: *unused.ExistingPolicyId, + ExistingPolicy: existingDoc, + NewDocument: newDoc, + Detach: false, + } + if *unused.RecommendedAction == "DETACH_POLICY" { + change.Detach = true + } + policyChange.Changes = append(policyChange.Changes, &change) + } + if len(policyChange.Changes) > 0 { + policyChanges = append(policyChanges, policyChange) + } else { + fmt.Printf("Not appending an empty set of policy changes for %s\n", policyChange.ResourceArn) + } + } + fmt.Printf("Returning %d policy changes\n", len(policyChanges)) + return policyChanges, nil +} diff --git a/lib/srv/discovery/fetchers/aws-sync/aws-sync.go b/lib/srv/discovery/fetchers/aws-sync/aws-sync.go index b4dd9c5c94c87..860315d7b7b97 100644 --- a/lib/srv/discovery/fetchers/aws-sync/aws-sync.go +++ b/lib/srv/discovery/fetchers/aws-sync/aws-sync.go @@ -20,6 +20,7 @@ package aws_sync import ( "context" + "github.com/gravitational/teleport/lib/auth/authclient" "reflect" "sync" "time" @@ -59,6 +60,8 @@ type Config struct { Integration string // DiscoveryConfigName if set, will be used to report the Discovery Config Status to the Auth Server. DiscoveryConfigName string + // The AccessPoint for sending auth commands + AccessPoint authclient.DiscoveryAccessPoint } // AssumeRole is the configuration for assuming an AWS role. @@ -138,6 +141,8 @@ type Resources struct { SAMLProviders []*accessgraphv1alpha.AWSSAMLProviderV1 // OIDCProviders is a list of OIDC providers. OIDCProviders []*accessgraphv1alpha.AWSOIDCProviderV1 + // PolicyChanges is a list of policy changes from the IAM Access Analyzer + PolicyChanges []*accessgraphv1alpha.AWSPolicyChange } func (r *Resources) count() int { @@ -203,6 +208,12 @@ func (a *awsFetcher) Poll(ctx context.Context, features Features) (*Resources, e result, err := a.poll(ctx, features) deduplicateResources(result) a.storeReport(result, err) + // Fetch policy changes outside the poll loop for max hackathonification + changes, taskErr := a.fetchPolicyChanges(ctx, result) + if taskErr != nil { + err = trace.NewAggregate(err, taskErr) + } + result.PolicyChanges = changes return result, trace.Wrap(err) } @@ -299,6 +310,7 @@ func (a *awsFetcher) poll(ctx context.Context, features Features) (*Resources, e if err := eGroup.Wait(); err != nil { return nil, trace.Wrap(err) } + return result, trace.NewAggregate(errs...) } diff --git a/lib/srv/discovery/fetchers/aws-sync/merge.go b/lib/srv/discovery/fetchers/aws-sync/merge.go index 6847243e13782..e6ea116b1d9e9 100644 --- a/lib/srv/discovery/fetchers/aws-sync/merge.go +++ b/lib/srv/discovery/fetchers/aws-sync/merge.go @@ -51,6 +51,7 @@ func MergeResources(results ...*Resources) *Resources { result.RDSDatabases = append(result.RDSDatabases, r.RDSDatabases...) result.SAMLProviders = append(result.SAMLProviders, r.SAMLProviders...) result.OIDCProviders = append(result.OIDCProviders, r.OIDCProviders...) + result.PolicyChanges = append(result.PolicyChanges, r.PolicyChanges...) } deduplicateResources(result) @@ -78,4 +79,5 @@ func deduplicateResources(result *Resources) { result.RDSDatabases = deduplicateSlice(result.RDSDatabases, rdsDbKey) result.SAMLProviders = deduplicateSlice(result.SAMLProviders, samlProvKey) result.OIDCProviders = deduplicateSlice(result.OIDCProviders, oidcProvKey) + result.PolicyChanges = deduplicateSlice(result.PolicyChanges, policyChangeKey) } diff --git a/lib/srv/discovery/fetchers/aws-sync/reconcile.go b/lib/srv/discovery/fetchers/aws-sync/reconcile.go index d9bfc3330ce69..60f69945f3327 100644 --- a/lib/srv/discovery/fetchers/aws-sync/reconcile.go +++ b/lib/srv/discovery/fetchers/aws-sync/reconcile.go @@ -59,6 +59,7 @@ func ReconcileResults(old *Resources, new *Resources) (upsert, delete *accessgra reconcile(old.RDSDatabases, new.RDSDatabases, rdsDbKey, rdsDbWrap), reconcile(old.SAMLProviders, new.SAMLProviders, samlProvKey, samlProvWrap), reconcile(old.OIDCProviders, new.OIDCProviders, oidcProvKey, oidcProvWrap), + reconcile(old.PolicyChanges, new.PolicyChanges, policyChangeKey, policyChangeWrap), } for _, res := range reconciledResources { upsert.Resources = append(upsert.Resources, res.upsert.Resources...) @@ -296,3 +297,11 @@ func oidcProvKey(provider *accessgraphv1alpha.AWSOIDCProviderV1) string { func oidcProvWrap(provider *accessgraphv1alpha.AWSOIDCProviderV1) *accessgraphv1alpha.AWSResource { return &accessgraphv1alpha.AWSResource{Resource: &accessgraphv1alpha.AWSResource_OidcProvider{OidcProvider: provider}} } + +func policyChangeKey(change *accessgraphv1alpha.AWSPolicyChange) string { + return fmt.Sprintf("%s", change.ResourceArn) +} + +func policyChangeWrap(change *accessgraphv1alpha.AWSPolicyChange) *accessgraphv1alpha.AWSResource { + return &accessgraphv1alpha.AWSResource{Resource: &accessgraphv1alpha.AWSResource_AwsPolicyChange{AwsPolicyChange: change}} +}