From 623096d8ce22a0bc11375eb856235011b04d1285 Mon Sep 17 00:00:00 2001 From: mstanbCO Date: Tue, 3 Feb 2026 13:37:10 -0800 Subject: [PATCH 1/6] add support for security insights --- cmd/baton-crowdstrike/main.go | 1 + go.mod | 2 +- pkg/config/conf.gen.go | 1 + pkg/config/config.go | 7 + pkg/connector/connector.go | 37 +++- pkg/connector/identity_protection.go | 280 +++++++++++++++++++++++++++ pkg/connector/resource_types.go | 8 + pkg/connector/security_insight.go | 145 ++++++++++++++ 8 files changed, 475 insertions(+), 6 deletions(-) create mode 100644 pkg/connector/identity_protection.go create mode 100644 pkg/connector/security_insight.go diff --git a/cmd/baton-crowdstrike/main.go b/cmd/baton-crowdstrike/main.go index b6e7efba..74ed4c00 100644 --- a/cmd/baton-crowdstrike/main.go +++ b/cmd/baton-crowdstrike/main.go @@ -49,6 +49,7 @@ func getConnector(ctx context.Context, cfg *config.Crowdstrike) (types.Connector cfg.CrowdstrikeClientId, cfg.CrowdstrikeClientSecret, cfg.Region, + cfg.EnableSecurityInsights, ) if err != nil { l.Error("error creating connector", zap.Error(err)) 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/config/conf.gen.go b/pkg/config/conf.gen.go index e9bc99f1..9de7fb49 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -7,6 +7,7 @@ type Crowdstrike struct { CrowdstrikeClientId string `mapstructure:"crowdstrike-client-id"` CrowdstrikeClientSecret string `mapstructure:"crowdstrike-client-secret"` Region string `mapstructure:"region"` + EnableSecurityInsights bool `mapstructure:"enable-security-insights"` } func (c *Crowdstrike) findFieldByTag(tagValue string) (any, bool) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 1f0718b9..de411c23 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,6 +24,12 @@ var ( field.WithDescription("CrowdStrike region to connect to. Options include 'us-1', 'us-2', 'eu-1', and 'us-gov-1'."), field.WithDefaultValue("us-1"), ) + EnableSecurityInsightsField = field.BoolField( + "enable-security-insights", + field.WithDisplayName("Enable Security Insights"), + field.WithDescription("Enable syncing of identity risk scores from CrowdStrike Identity Protection. Requires Identity Protection Entities: Read API scope."), + field.WithDefaultValue(false), + ) // ConfigurationFields defines the external configuration required for the // connector to run. @@ -31,6 +37,7 @@ var ( ClientIdField, ClientSecretField, RegionField, + EnableSecurityInsightsField, } ) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 38404853..73169c57 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -15,20 +15,35 @@ import ( ) type Connector struct { - client *fClient.CrowdStrikeAPISpecification + client *fClient.CrowdStrikeAPISpecification + enableSecurityInsights bool + clientId string + clientSecret string + host string } func (o *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncer { - return []connectorbuilder.ResourceSyncer{ + syncers := []connectorbuilder.ResourceSyncer{ userBuilder(o.client), roleBuilder(o.client), } + + if o.enableSecurityInsights { + syncers = append(syncers, securityInsightBuilder(ctx, o.client, o.clientId, o.clientSecret, o.host)) + } + + return syncers } func (o *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { + description := "Connector syncing CrowdStrike users and their roles to Baton." + if o.enableSecurityInsights { + description = "Connector syncing CrowdStrike users, roles, and identity risk scores to Baton." + } + return &v2.ConnectorMetadata{ DisplayName: "CrowdStrike", - Description: "Connector syncing CrowdStrike users and their roles to Baton.", + Description: description, }, nil } @@ -68,11 +83,19 @@ func (o *Connector) Validate(ctx context.Context) (annotations.Annotations, erro return nil, wrapCrowdStrikeError(err, "validate: unable to retrieve role details") } + // validate Identity Protection API access if security insights are enabled + if o.enableSecurityInsights { + ipClient := NewIdentityProtectionClient(ctx, o.clientId, o.clientSecret, o.host) + if err := ipClient.ValidateAccess(ctx); err != nil { + return nil, wrapCrowdStrikeError(err, "validate: unable to access Identity Protection API - ensure the API client has 'Identity Protection Entities: Read' scope") + } + } + return nil, nil } // New returns the CrowdStrike connector. -func New(ctx context.Context, clientId, clientSecret string, region string) (*Connector, error) { +func New(ctx context.Context, clientId, clientSecret string, region string, enableSecurityInsights bool) (*Connector, error) { var cloudRegion falcon.CloudType switch region { case "us-1": @@ -98,6 +121,10 @@ func New(ctx context.Context, clientId, clientSecret string, region string) (*Co } return &Connector{ - client: client, + client: client, + enableSecurityInsights: enableSecurityInsights, + 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..6bc0418a --- /dev/null +++ b/pkg/connector/identity_protection.go @@ -0,0 +1,280 @@ +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, "POST", 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{ + Type: rf.Type, + Severity: rf.Severity, + }) + } + + 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..4fe9c947 --- /dev/null +++ b/pkg/connector/security_insight.go @@ -0,0 +1,145 @@ +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 + + if identity.SecondaryDisplayName != "" { + resourceID = identity.SecondaryDisplayName + } else if len(identity.EmailAddresses) > 0 { + resourceID = identity.EmailAddresses[0] + } else { + 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 (format as percentage) + riskScoreStr := strconv.FormatFloat(identity.RiskScore, 'f', 2, 64) + + // Build trait options + traitOpts := []rs.SecurityInsightTraitOption{ + rs.WithRiskScore(riskScoreStr), + } + + // 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), + } +} From 331ca916c4e9c769bb717d4b567d5d7511c4fbc1 Mon Sep 17 00:00:00 2001 From: mstanbCO Date: Tue, 3 Feb 2026 14:41:09 -0800 Subject: [PATCH 2/6] linter --- pkg/connector/identity_protection.go | 11 ++++------- pkg/connector/security_insight.go | 7 ++++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/pkg/connector/identity_protection.go b/pkg/connector/identity_protection.go index 6bc0418a..4a6f8771 100644 --- a/pkg/connector/identity_protection.go +++ b/pkg/connector/identity_protection.go @@ -178,7 +178,7 @@ func (c *IdentityProtectionClient) GetIdentityRiskScores(ctx context.Context, pa } // Create the HTTP request - req, err := http.NewRequestWithContext(ctx, "POST", c.endpoint, bytes.NewReader(bodyBytes)) + 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) } @@ -228,10 +228,7 @@ func (c *IdentityProtectionClient) GetIdentityRiskScores(ctx context.Context, pa for _, entity := range graphQLResp.Data.Entities.Nodes { riskFactors := make([]RiskFactor, 0, len(entity.RiskFactors)) for _, rf := range entity.RiskFactors { - riskFactors = append(riskFactors, RiskFactor{ - Type: rf.Type, - Severity: rf.Severity, - }) + riskFactors = append(riskFactors, RiskFactor(rf)) } results = append(results, IdentityRiskData{ @@ -270,10 +267,10 @@ func extractRateLimitInfo(resp *http.Response) RateLimitInfo { var limit, remaining int64 if limitStr := resp.Header.Get("X-RateLimit-Limit"); limitStr != "" { - fmt.Sscanf(limitStr, "%d", &limit) + _, _ = fmt.Sscanf(limitStr, "%d", &limit) } if remainingStr := resp.Header.Get("X-RateLimit-Remaining"); remainingStr != "" { - fmt.Sscanf(remainingStr, "%d", &remaining) + _, _ = fmt.Sscanf(remainingStr, "%d", &remaining) } return NewRateLimitInfo(limit, remaining) diff --git a/pkg/connector/security_insight.go b/pkg/connector/security_insight.go index 4fe9c947..21857a0c 100644 --- a/pkg/connector/security_insight.go +++ b/pkg/connector/security_insight.go @@ -34,11 +34,12 @@ func securityInsightResource(identity IdentityRiskData) (*v2.Resource, error) { var resourceID string var email string - if identity.SecondaryDisplayName != "" { + switch { + case identity.SecondaryDisplayName != "": resourceID = identity.SecondaryDisplayName - } else if len(identity.EmailAddresses) > 0 { + case len(identity.EmailAddresses) > 0: resourceID = identity.EmailAddresses[0] - } else { + default: resourceID = identity.PrimaryDisplayName } From 69d31575d87a1f3d699b535e0a420b7c63c84235 Mon Sep 17 00:00:00 2001 From: mstanbCO Date: Tue, 3 Feb 2026 15:12:59 -0800 Subject: [PATCH 3/6] add risk factors --- pkg/connector/security_insight.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/connector/security_insight.go b/pkg/connector/security_insight.go index 21857a0c..0c696ebd 100644 --- a/pkg/connector/security_insight.go +++ b/pkg/connector/security_insight.go @@ -64,6 +64,20 @@ func securityInsightResource(identity IdentityRiskData) (*v2.Resource, error) { 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)) From bda5cae735bcc859b4885ffb445d552b9341718f Mon Sep 17 00:00:00 2001 From: mstanbCO Date: Tue, 3 Feb 2026 15:37:29 -0800 Subject: [PATCH 4/6] update read me --- README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1104b580..4a0e29f1 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 only if `--enable-security-insights` is enabled | + +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) - requires `--enable-security-insights` flag # Contributing, Support and Issues @@ -74,6 +82,7 @@ Flags: --client-secret string The client secret used to authenticate with ConductorOne ($BATON_CLIENT_SECRET) --crowdstrike-client-id string required: CrowdStrike client ID used to generate the access token. ($BATON_CROWDSTRIKE_CLIENT_ID) --crowdstrike-client-secret string required: CrowdStrike client secret used to generate the access token. ($BATON_CROWDSTRIKE_CLIENT_SECRET) + --enable-security-insights Enable syncing of identity risk scores from CrowdStrike Identity Protection ($BATON_ENABLE_SECURITY_INSIGHTS) -f, --file string The path to the c1z file to sync with ($BATON_FILE) (default "sync.c1z") -h, --help help for baton-crowdstrike --log-format string The output format for logs: json, console ($BATON_LOG_FORMAT) (default "json") @@ -86,3 +95,12 @@ Flags: Use "baton-crowdstrike [command] --help" for more information about a command. ``` + +## Security Insights + +When `--enable-security-insights` is enabled, the connector will sync 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 use this feature, your CrowdStrike API client must have the **Identity Protection Entities: Read** scope enabled. From fff806156902b64efb85b9d22c4c928adb338618 Mon Sep 17 00:00:00 2001 From: mstanbCO Date: Thu, 5 Feb 2026 16:13:11 -0800 Subject: [PATCH 5/6] remove enablesecurityinsights flag --- README.md | 9 ++++--- cmd/baton-crowdstrike/main.go | 1 - pkg/config/conf.gen.go | 1 - pkg/config/config.go | 7 ------ pkg/connector/connector.go | 44 ++++++++++------------------------- 5 files changed, 16 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 4a0e29f1..701814f9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Connector requires **client id and secret** to exchange for access token that is | Scope | Required | Description | |-------|----------|-------------| | **User Management: Read** | Yes | Required to sync users and roles | -| **Identity Protection Entities: Read** | No | Required only if `--enable-security-insights` is enabled | +| **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. @@ -55,7 +55,7 @@ baton resources - Users - Roles -- Security Insights (identity risk scores) - requires `--enable-security-insights` flag +- Security Insights (identity risk scores) # Contributing, Support and Issues @@ -82,7 +82,6 @@ Flags: --client-secret string The client secret used to authenticate with ConductorOne ($BATON_CLIENT_SECRET) --crowdstrike-client-id string required: CrowdStrike client ID used to generate the access token. ($BATON_CROWDSTRIKE_CLIENT_ID) --crowdstrike-client-secret string required: CrowdStrike client secret used to generate the access token. ($BATON_CROWDSTRIKE_CLIENT_SECRET) - --enable-security-insights Enable syncing of identity risk scores from CrowdStrike Identity Protection ($BATON_ENABLE_SECURITY_INSIGHTS) -f, --file string The path to the c1z file to sync with ($BATON_FILE) (default "sync.c1z") -h, --help help for baton-crowdstrike --log-format string The output format for logs: json, console ($BATON_LOG_FORMAT) (default "json") @@ -98,9 +97,9 @@ Use "baton-crowdstrike [command] --help" for more information about a command. ## Security Insights -When `--enable-security-insights` is enabled, the connector will sync identity risk scores from CrowdStrike Identity Protection. This includes: +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 use this feature, your CrowdStrike API client must have the **Identity Protection Entities: Read** scope enabled. +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/cmd/baton-crowdstrike/main.go b/cmd/baton-crowdstrike/main.go index 74ed4c00..b6e7efba 100644 --- a/cmd/baton-crowdstrike/main.go +++ b/cmd/baton-crowdstrike/main.go @@ -49,7 +49,6 @@ func getConnector(ctx context.Context, cfg *config.Crowdstrike) (types.Connector cfg.CrowdstrikeClientId, cfg.CrowdstrikeClientSecret, cfg.Region, - cfg.EnableSecurityInsights, ) if err != nil { l.Error("error creating connector", zap.Error(err)) diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index 9de7fb49..e9bc99f1 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -7,7 +7,6 @@ type Crowdstrike struct { CrowdstrikeClientId string `mapstructure:"crowdstrike-client-id"` CrowdstrikeClientSecret string `mapstructure:"crowdstrike-client-secret"` Region string `mapstructure:"region"` - EnableSecurityInsights bool `mapstructure:"enable-security-insights"` } func (c *Crowdstrike) findFieldByTag(tagValue string) (any, bool) { diff --git a/pkg/config/config.go b/pkg/config/config.go index de411c23..1f0718b9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,12 +24,6 @@ var ( field.WithDescription("CrowdStrike region to connect to. Options include 'us-1', 'us-2', 'eu-1', and 'us-gov-1'."), field.WithDefaultValue("us-1"), ) - EnableSecurityInsightsField = field.BoolField( - "enable-security-insights", - field.WithDisplayName("Enable Security Insights"), - field.WithDescription("Enable syncing of identity risk scores from CrowdStrike Identity Protection. Requires Identity Protection Entities: Read API scope."), - field.WithDefaultValue(false), - ) // ConfigurationFields defines the external configuration required for the // connector to run. @@ -37,7 +31,6 @@ var ( ClientIdField, ClientSecretField, RegionField, - EnableSecurityInsightsField, } ) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 73169c57..010c8fe4 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -15,35 +15,24 @@ import ( ) type Connector struct { - client *fClient.CrowdStrikeAPISpecification - enableSecurityInsights bool - clientId string - clientSecret string - host string + client *fClient.CrowdStrikeAPISpecification + clientId string + clientSecret string + host string } func (o *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncer { - syncers := []connectorbuilder.ResourceSyncer{ + return []connectorbuilder.ResourceSyncer{ userBuilder(o.client), roleBuilder(o.client), + securityInsightBuilder(ctx, o.client, o.clientId, o.clientSecret, o.host), } - - if o.enableSecurityInsights { - syncers = append(syncers, securityInsightBuilder(ctx, o.client, o.clientId, o.clientSecret, o.host)) - } - - return syncers } func (o *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { - description := "Connector syncing CrowdStrike users and their roles to Baton." - if o.enableSecurityInsights { - description = "Connector syncing CrowdStrike users, roles, and identity risk scores to Baton." - } - return &v2.ConnectorMetadata{ DisplayName: "CrowdStrike", - Description: description, + Description: "Connector syncing CrowdStrike users, roles, and identity risk scores to Baton.", }, nil } @@ -83,19 +72,11 @@ func (o *Connector) Validate(ctx context.Context) (annotations.Annotations, erro return nil, wrapCrowdStrikeError(err, "validate: unable to retrieve role details") } - // validate Identity Protection API access if security insights are enabled - if o.enableSecurityInsights { - ipClient := NewIdentityProtectionClient(ctx, o.clientId, o.clientSecret, o.host) - if err := ipClient.ValidateAccess(ctx); err != nil { - return nil, wrapCrowdStrikeError(err, "validate: unable to access Identity Protection API - ensure the API client has 'Identity Protection Entities: Read' scope") - } - } - return nil, nil } // New returns the CrowdStrike connector. -func New(ctx context.Context, clientId, clientSecret string, region string, enableSecurityInsights bool) (*Connector, error) { +func New(ctx context.Context, clientId, clientSecret string, region string) (*Connector, error) { var cloudRegion falcon.CloudType switch region { case "us-1": @@ -121,10 +102,9 @@ func New(ctx context.Context, clientId, clientSecret string, region string, enab } return &Connector{ - client: client, - enableSecurityInsights: enableSecurityInsights, - clientId: clientId, - clientSecret: clientSecret, - host: cloudRegion.Host(), + client: client, + clientId: clientId, + clientSecret: clientSecret, + host: cloudRegion.Host(), }, nil } From 9d1aecfe3064becbc9952d0d28db13f696896cfb Mon Sep 17 00:00:00 2001 From: mstanbCO Date: Thu, 5 Feb 2026 16:22:15 -0800 Subject: [PATCH 6/6] fix comment --- pkg/connector/security_insight.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/security_insight.go b/pkg/connector/security_insight.go index 0c696ebd..333f5c58 100644 --- a/pkg/connector/security_insight.go +++ b/pkg/connector/security_insight.go @@ -56,7 +56,7 @@ func securityInsightResource(identity IdentityRiskData) (*v2.Resource, error) { displayName = fmt.Sprintf("Risk Score: %s", resourceID) } - // Convert risk score to string (format as percentage) + // Convert risk score to string (e.g. 0.65) riskScoreStr := strconv.FormatFloat(identity.RiskScore, 'f', 2, 64) // Build trait options