diff --git a/README.md b/README.md index 1104b580..701814f9 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,16 @@ Check out [Baton](https://github.com/conductorone/baton) to learn more about the # Prerequisites -Connector requires **client id and secret** to exchange for access token that is later used throughout the communication with API. To obtain these credentials, you have to create API client in CrowdStrike. You must be designated as Falcon administrator role to create API client in CrowdStrike (more info on obtaining access and creating clients [here](https://www.crowdstrike.com/blog/tech-center/get-access-falcon-apis/)). Administrator will have to provide you with credentials that have access at least to **User management** scope. +Connector requires **client id and secret** to exchange for access token that is later used throughout the communication with API. To obtain these credentials, you have to create API client in CrowdStrike. You must be designated as Falcon administrator role to create API client in CrowdStrike (more info on obtaining access and creating clients [here](https://www.crowdstrike.com/blog/tech-center/get-access-falcon-apis/)). -After you have obtained client id and secret, you can use them with connector. You can do this by setting `BATON_CLIENT_ID` and `BATON_CLIENT_SECRET` environment variables or by passing them as flags to `baton-crowdstrike` command. +## Required API Scopes + +| Scope | Required | Description | +|-------|----------|-------------| +| **User Management: Read** | Yes | Required to sync users and roles | +| **Identity Protection Entities: Read** | No | Required to sync identity risk scores (security insights) | + +After you have obtained client id and secret, you can use them with connector. You can do this by setting `BATON_CROWDSTRIKE_CLIENT_ID` and `BATON_CROWDSTRIKE_CLIENT_SECRET` environment variables or by passing them as flags to `baton-crowdstrike` command. # Getting Started @@ -48,6 +55,7 @@ baton resources - Users - Roles +- Security Insights (identity risk scores) # Contributing, Support and Issues @@ -86,3 +94,12 @@ Flags: Use "baton-crowdstrike [command] --help" for more information about a command. ``` + +## Security Insights + +The connector syncs identity risk scores from CrowdStrike Identity Protection. This includes: + +- **Risk Score**: A numerical score (0-1) indicating the identity's risk level +- **Risk Factors**: The factors contributing to the risk score (e.g., "WEAK_PASSWORD (HIGH)", "MFA_NOT_ENABLED (MEDIUM)") + +To sync security insights, your CrowdStrike API client must have the **Identity Protection Entities: Read** scope enabled. The security_insight resource type is disabled by default and can be enabled through ConductorOne. diff --git a/go.mod b/go.mod index 4ea5647f..f4798853 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/quasilyte/go-ruleguard/dsl v0.3.23 go.uber.org/zap v1.27.1 + golang.org/x/oauth2 v0.33.0 google.golang.org/grpc v1.78.0 ) @@ -124,7 +125,6 @@ require ( golang.org/x/crypto v0.44.0 // indirect golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 38404853..010c8fe4 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -15,20 +15,24 @@ import ( ) type Connector struct { - client *fClient.CrowdStrikeAPISpecification + client *fClient.CrowdStrikeAPISpecification + clientId string + clientSecret string + host string } func (o *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncer { return []connectorbuilder.ResourceSyncer{ userBuilder(o.client), roleBuilder(o.client), + securityInsightBuilder(ctx, o.client, o.clientId, o.clientSecret, o.host), } } func (o *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { return &v2.ConnectorMetadata{ DisplayName: "CrowdStrike", - Description: "Connector syncing CrowdStrike users and their roles to Baton.", + Description: "Connector syncing CrowdStrike users, roles, and identity risk scores to Baton.", }, nil } @@ -98,6 +102,9 @@ func New(ctx context.Context, clientId, clientSecret string, region string) (*Co } return &Connector{ - client: client, + client: client, + clientId: clientId, + clientSecret: clientSecret, + host: cloudRegion.Host(), }, nil } diff --git a/pkg/connector/identity_protection.go b/pkg/connector/identity_protection.go new file mode 100644 index 00000000..4a6f8771 --- /dev/null +++ b/pkg/connector/identity_protection.go @@ -0,0 +1,277 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "golang.org/x/oauth2/clientcredentials" +) + +// IdentityRiskData represents the risk data for a single identity from CrowdStrike. +type IdentityRiskData struct { + PrimaryDisplayName string `json:"primaryDisplayName"` + SecondaryDisplayName string `json:"secondaryDisplayName"` + EmailAddresses []string `json:"emailAddresses"` + RiskScore float64 `json:"riskScore"` + RiskScoreSeverity string `json:"riskScoreSeverity"` + RiskFactors []RiskFactor `json:"riskFactors"` +} + +// RiskFactor represents a single factor contributing to an identity's risk score. +type RiskFactor struct { + Type string `json:"type"` + Severity string `json:"severity"` +} + +// graphQLRequest represents a GraphQL request body. +type graphQLRequest struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +// graphQLResponse represents the GraphQL response for entities query. +type graphQLResponse struct { + Data *graphQLData `json:"data"` + Errors []graphQLError `json:"errors,omitempty"` +} + +type graphQLData struct { + Entities *entitiesResult `json:"entities"` +} + +type entitiesResult struct { + PageInfo *pageInfo `json:"pageInfo"` + Nodes []identityEntity `json:"nodes"` +} + +type pageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` +} + +type identityEntity struct { + PrimaryDisplayName string `json:"primaryDisplayName"` + SecondaryDisplayName string `json:"secondaryDisplayName"` + EmailAddresses []string `json:"emailAddresses"` + RiskScore float64 `json:"riskScore"` + RiskScoreSeverity string `json:"riskScoreSeverity"` + RiskFactors []riskFactor `json:"riskFactors"` +} + +type riskFactor struct { + Type string `json:"type"` + Severity string `json:"severity"` +} + +type graphQLError struct { + Message string `json:"message"` +} + +const identityRiskQuery = ` +query GetIdentityRiskScores($first: Int, $after: Cursor) { + entities(types: [USER], sortKey: PRIMARY_DISPLAY_NAME, first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + primaryDisplayName + secondaryDisplayName + riskScore + riskScoreSeverity + riskFactors { + type + severity + } + ... on UserEntity { + emailAddresses + } + } + } +} +` + +// IdentityProtectionClient provides methods to interact with CrowdStrike's Identity Protection API. +type IdentityProtectionClient struct { + httpClient *http.Client + endpoint string +} + +// NewIdentityProtectionClient creates a new Identity Protection client with OAuth2 authentication. +func NewIdentityProtectionClient(ctx context.Context, clientID, clientSecret, host string) *IdentityProtectionClient { + // Create OAuth2 client credentials config + config := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: "https://" + host + "/oauth2/token", + } + + // Create the OAuth2 HTTP client + httpClient := config.Client(ctx) + httpClient.Timeout = 30 * time.Second + + // Wrap the transport to add user-agent header + httpClient.Transport = &identityProtectionTransport{ + base: httpClient.Transport, + } + + return &IdentityProtectionClient{ + httpClient: httpClient, + endpoint: "https://" + host + "/identity-protection/combined/graphql/v1", + } +} + +// identityProtectionTransport adds custom headers to requests. +type identityProtectionTransport struct { + base http.RoundTripper +} + +func (t *identityProtectionTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", "baton-crowdstrike") + if t.base == nil { + return http.DefaultTransport.RoundTrip(req) + } + return t.base.RoundTrip(req) +} + +// RefreshContext updates the OAuth2 context used for token refresh. +// This should be called before making requests to ensure the token can be refreshed. +func (c *IdentityProtectionClient) RefreshContext(ctx context.Context, clientID, clientSecret, host string) { + config := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: "https://" + host + "/oauth2/token", + } + httpClient := config.Client(ctx) + httpClient.Timeout = 30 * time.Second + httpClient.Transport = &identityProtectionTransport{ + base: httpClient.Transport, + } + c.httpClient = httpClient +} + +// GetIdentityRiskScores fetches identity risk scores from CrowdStrike Identity Protection. +// It uses the GraphQL API to retrieve risk data for all users. +func (c *IdentityProtectionClient) GetIdentityRiskScores(ctx context.Context, pageSize int, cursor string) ([]IdentityRiskData, string, bool, RateLimitInfo, error) { + // Build variables for pagination + variables := map[string]interface{}{ + "first": pageSize, + } + if cursor != "" { + variables["after"] = cursor + } + + reqBody := graphQLRequest{ + Query: identityRiskQuery, + Variables: variables, + } + + // Create the request body + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, "", false, RateLimitInfo{}, fmt.Errorf("failed to marshal GraphQL request: %w", err) + } + + // Create the HTTP request + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, "", false, RateLimitInfo{}, fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + // Make the HTTP request using the authenticated client + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, "", false, RateLimitInfo{}, fmt.Errorf("failed to execute identity protection request: %w", err) + } + defer resp.Body.Close() + + // Extract rate limit info from response headers + rateLimitInfo := extractRateLimitInfo(resp) + + // Check for error status codes + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, "", false, rateLimitInfo, fmt.Errorf("identity protection API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + // Parse the response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", false, rateLimitInfo, fmt.Errorf("failed to read response body: %w", err) + } + + var graphQLResp graphQLResponse + if err := json.Unmarshal(respBody, &graphQLResp); err != nil { + return nil, "", false, rateLimitInfo, fmt.Errorf("failed to parse GraphQL response: %w", err) + } + + // Check for GraphQL errors + if len(graphQLResp.Errors) > 0 { + return nil, "", false, rateLimitInfo, fmt.Errorf("GraphQL error: %s", graphQLResp.Errors[0].Message) + } + + // Check if we have data + if graphQLResp.Data == nil || graphQLResp.Data.Entities == nil { + return nil, "", false, rateLimitInfo, nil + } + + // Convert to IdentityRiskData + results := make([]IdentityRiskData, 0, len(graphQLResp.Data.Entities.Nodes)) + for _, entity := range graphQLResp.Data.Entities.Nodes { + riskFactors := make([]RiskFactor, 0, len(entity.RiskFactors)) + for _, rf := range entity.RiskFactors { + riskFactors = append(riskFactors, RiskFactor(rf)) + } + + results = append(results, IdentityRiskData{ + PrimaryDisplayName: entity.PrimaryDisplayName, + SecondaryDisplayName: entity.SecondaryDisplayName, + EmailAddresses: entity.EmailAddresses, + RiskScore: entity.RiskScore, + RiskScoreSeverity: entity.RiskScoreSeverity, + RiskFactors: riskFactors, + }) + } + + // Get pagination info + nextCursor := "" + hasNextPage := false + if graphQLResp.Data.Entities.PageInfo != nil { + hasNextPage = graphQLResp.Data.Entities.PageInfo.HasNextPage + nextCursor = graphQLResp.Data.Entities.PageInfo.EndCursor + } + + return results, nextCursor, hasNextPage, rateLimitInfo, nil +} + +// ValidateAccess checks if the connector has access to the Identity Protection API. +func (c *IdentityProtectionClient) ValidateAccess(ctx context.Context) error { + // Try to fetch a single entity to validate access + _, _, _, _, err := c.GetIdentityRiskScores(ctx, 1, "") + if err != nil { + return fmt.Errorf("identity protection API access validation failed: %w", err) + } + return nil +} + +// extractRateLimitInfo extracts rate limit information from HTTP response headers. +func extractRateLimitInfo(resp *http.Response) RateLimitInfo { + var limit, remaining int64 + + if limitStr := resp.Header.Get("X-RateLimit-Limit"); limitStr != "" { + _, _ = fmt.Sscanf(limitStr, "%d", &limit) + } + if remainingStr := resp.Header.Get("X-RateLimit-Remaining"); remainingStr != "" { + _, _ = fmt.Sscanf(remainingStr, "%d", &remaining) + } + + return NewRateLimitInfo(limit, remaining) +} diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 6af8d6e8..1908b0f1 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -21,4 +21,12 @@ var ( v2.ResourceType_TRAIT_ROLE, }, } + resourceTypeSecurityInsight = &v2.ResourceType{ + Id: "security_insight", + DisplayName: "Identity Risk Score", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_SECURITY_INSIGHT, + }, + Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}), + } ) diff --git a/pkg/connector/security_insight.go b/pkg/connector/security_insight.go new file mode 100644 index 00000000..333f5c58 --- /dev/null +++ b/pkg/connector/security_insight.go @@ -0,0 +1,160 @@ +package connector + +import ( + "context" + "fmt" + "strconv" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + fClient "github.com/crowdstrike/gofalcon/falcon/client" +) + +const ( + // securityInsightPageSize is the number of identity risk scores to fetch per page. + securityInsightPageSize = 100 +) + +type securityInsightResourceType struct { + resourceType *v2.ResourceType + client *fClient.CrowdStrikeAPISpecification + ipClient *IdentityProtectionClient +} + +func (s *securityInsightResourceType) ResourceType(_ context.Context) *v2.ResourceType { + return s.resourceType +} + +// securityInsightResource creates a new security insight resource for an identity risk score. +func securityInsightResource(identity IdentityRiskData) (*v2.Resource, error) { + // Determine the unique identifier for this security insight + // Use secondaryDisplayName (often the UPN/email) as the primary identifier + var resourceID string + var email string + + switch { + case identity.SecondaryDisplayName != "": + resourceID = identity.SecondaryDisplayName + case len(identity.EmailAddresses) > 0: + resourceID = identity.EmailAddresses[0] + default: + resourceID = identity.PrimaryDisplayName + } + + // Get the primary email for targeting + if len(identity.EmailAddresses) > 0 { + email = identity.EmailAddresses[0] + } else if validateEmail(identity.SecondaryDisplayName) { + email = identity.SecondaryDisplayName + } + + // Build the display name for the resource + displayName := fmt.Sprintf("Risk Score: %s", identity.PrimaryDisplayName) + if identity.PrimaryDisplayName == "" { + displayName = fmt.Sprintf("Risk Score: %s", resourceID) + } + + // Convert risk score to string (e.g. 0.65) + riskScoreStr := strconv.FormatFloat(identity.RiskScore, 'f', 2, 64) + + // Build trait options + traitOpts := []rs.SecurityInsightTraitOption{ + rs.WithRiskScore(riskScoreStr), + } + + // Add risk factors if present + if len(identity.RiskFactors) > 0 { + factors := make([]string, 0, len(identity.RiskFactors)) + for _, rf := range identity.RiskFactors { + // Format as "Type (Severity)" for clarity + factor := rf.Type + if rf.Severity != "" { + factor = fmt.Sprintf("%s (%s)", rf.Type, rf.Severity) + } + factors = append(factors, factor) + } + traitOpts = append(traitOpts, rs.WithRiskScoreFactors(factors...)) + } + + // Add target - prefer AppUserTarget since we have both email and external ID + if email != "" { + traitOpts = append(traitOpts, rs.WithInsightAppUserTarget(email, resourceID)) + } else { + // Fall back to external resource target if no email available + traitOpts = append(traitOpts, rs.WithInsightExternalResourceTarget(resourceID, "crowdstrike")) + } + + // Create the security insight resource + resource, err := rs.NewSecurityInsightResource( + displayName, + resourceTypeSecurityInsight, + resourceID, + traitOpts..., + ) + if err != nil { + return nil, fmt.Errorf("failed to create security insight resource: %w", err) + } + + return resource, nil +} + +func (s *securityInsightResourceType) List(ctx context.Context, _ *v2.ResourceId, pt *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + // Parse the page token to get the cursor + cursor := pt.Token + + // Fetch identity risk scores from CrowdStrike + identities, nextCursor, hasNextPage, rateLimitInfo, err := s.ipClient.GetIdentityRiskScores(ctx, securityInsightPageSize, cursor) + if err != nil { + return nil, "", nil, wrapCrowdStrikeError(err, "security insight list: failed to fetch identity risk scores") + } + + // Build rate limit annotations + annos := WithRateLimitAnnotations(rateLimitInfo) + + // If no identities found, return empty result + if len(identities) == 0 { + return nil, "", annos, nil + } + + // Convert identities to resources + resources := make([]*v2.Resource, 0, len(identities)) + for _, identity := range identities { + // Skip identities without a risk score (value of 0 means no assessment yet) + // We still include them but they will have a risk score of "0" + + resource, err := securityInsightResource(identity) + if err != nil { + // Log the error but continue processing other identities + continue + } + resources = append(resources, resource) + } + + // Determine the next page token + nextPageToken := "" + if hasNextPage && nextCursor != "" { + nextPageToken = nextCursor + } + + return resources, nextPageToken, annos, nil +} + +func (s *securityInsightResourceType) Entitlements(ctx context.Context, resource *v2.Resource, token *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + // Security insights do not have entitlements + return nil, "", nil, nil +} + +func (s *securityInsightResourceType) Grants(ctx context.Context, resource *v2.Resource, token *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + // Security insights do not have grants + return nil, "", nil, nil +} + +func securityInsightBuilder(ctx context.Context, client *fClient.CrowdStrikeAPISpecification, clientID, clientSecret, host string) *securityInsightResourceType { + return &securityInsightResourceType{ + resourceType: resourceTypeSecurityInsight, + client: client, + ipClient: NewIdentityProtectionClient(ctx, clientID, clientSecret, host), + } +}