Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -48,6 +55,7 @@ baton resources

- Users
- Roles
- Security Insights (identity risk scores)

# Contributing, Support and Issues

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
277 changes: 277 additions & 0 deletions pkg/connector/identity_protection.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions pkg/connector/resource_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}),
}
)
Loading
Loading