From c3f0e1cbd2bded07ebdeaf279d0fb2e3b4aca275 Mon Sep 17 00:00:00 2001 From: Kashif Khan <70996046+kashifkhan0771@users.noreply.github.com> Date: Tue, 27 May 2025 22:56:30 +0500 Subject: [PATCH] Updated LaunchDarkly Analyzer and Detector (#4178) * updated launchdarkly analyzer and detector * updated launchdarkly analyzer test * fixed linter * remove extradata nil check --- .../analyzers/launchdarkly/launchdarkly.go | 19 ++- .../launchdarkly/launchdarkly_test.go | 2 +- .../analyzers/launchdarkly/result_output.json | 11 +- pkg/detectors/launchdarkly/launchdarkly.go | 117 +++++++++++------- .../launchdarkly_integration_test.go | 1 + .../launchdarkly/launchdarkly_test.go | 27 ++-- 6 files changed, 114 insertions(+), 63 deletions(-) diff --git a/pkg/analyzer/analyzers/launchdarkly/launchdarkly.go b/pkg/analyzer/analyzers/launchdarkly/launchdarkly.go index eb2d9c9f3..91dea5dd5 100644 --- a/pkg/analyzer/analyzers/launchdarkly/launchdarkly.go +++ b/pkg/analyzer/analyzers/launchdarkly/launchdarkly.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/fatih/color" "github.com/jedib0t/go-pretty/v6/table" @@ -31,6 +32,10 @@ func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analy return nil, errors.New("key not found in credentials info") } + if isSDKKey(key) { + return nil, errors.New("sdk keys cannot be analyzed") + } + info, err := AnalyzePermissions(a.Cfg, key) if err != nil { return nil, err @@ -40,6 +45,13 @@ func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analy } func AnalyzeAndPrintPermissions(cfg *config.Config, token string) { + if isSDKKey(token) { + color.Yellow("\n[!] The Provided key is an SDK Key. SDK Keys are sensitive but used to configure LaunchDarkly SDKs") + color.Green("\n[i] Docs: https://launchdarkly.com/docs/home/account/environment/settings#copy-and-reset-sdk-credentials-for-an-environment") + + return + } + info, err := AnalyzePermissions(cfg, token) if err != nil { // just print the error in cli and continue as a partial success @@ -86,7 +98,7 @@ func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { } result := analyzers.AnalyzerResult{ - AnalyzerType: analyzers.AnalyzerTypeElevenLabs, + AnalyzerType: analyzers.AnalyzerTypeLaunchDarkly, Metadata: map[string]any{}, Bindings: make([]analyzers.Binding, 0), } @@ -202,3 +214,8 @@ func printResources(resources []Resource) { } callerTable.Render() } + +// isSDKKey check if the key provided is an SDK Key or not +func isSDKKey(key string) bool { + return strings.HasPrefix(key, "sdk-") +} diff --git a/pkg/analyzer/analyzers/launchdarkly/launchdarkly_test.go b/pkg/analyzer/analyzers/launchdarkly/launchdarkly_test.go index b52e66f82..7b9ccf7f1 100644 --- a/pkg/analyzer/analyzers/launchdarkly/launchdarkly_test.go +++ b/pkg/analyzer/analyzers/launchdarkly/launchdarkly_test.go @@ -33,7 +33,7 @@ func TestAnalyzer_Analyze(t *testing.T) { wantErr bool }{ { - name: "valid LauncgDarkly token", + name: "valid LaunchDarkly token", key: key, want: expectedOutput, wantErr: false, diff --git a/pkg/analyzer/analyzers/launchdarkly/result_output.json b/pkg/analyzer/analyzers/launchdarkly/result_output.json index 45e8714cb..8570eaa3d 100644 --- a/pkg/analyzer/analyzers/launchdarkly/result_output.json +++ b/pkg/analyzer/analyzers/launchdarkly/result_output.json @@ -1,5 +1,5 @@ { - "AnalyzerType": 6, + "AnalyzerType": 31, "Bindings": [ { "Resource": { @@ -29,7 +29,10 @@ "Name": "Roxanne Tampus", "FullyQualifiedName": "launchdarkly/member/61543c5956be60235562486f", "Type": "Member", - "Metadata": {}, + "Metadata": { + "Email": "knightmoverchan@gmail.com", + "Role": "owner" + }, "Parent": null }, "Permission": { @@ -80,7 +83,9 @@ "Name": "secretscanner", "FullyQualifiedName": "launchdarkly/proj/default/flag/secretscanner", "Type": "Feature Flags", - "Metadata": {}, + "Metadata": { + "Kind": "boolean" + }, "Parent": { "Name": "secretscanner", "FullyQualifiedName": "launchdarkly/proj/61543c5956be60235562486e", diff --git a/pkg/detectors/launchdarkly/launchdarkly.go b/pkg/detectors/launchdarkly/launchdarkly.go index c101264a5..16568fca2 100644 --- a/pkg/detectors/launchdarkly/launchdarkly.go +++ b/pkg/detectors/launchdarkly/launchdarkly.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "strings" @@ -44,6 +45,14 @@ type callerIdentity struct { ServiceToken bool `json:"serviceToken"` } +func (s Scanner) getClient() *http.Client { + if s.client == nil { + return defaultClient + } + + return s.client +} + // We are not including "mob-" because client keys are not sensitive. // They are expected to be public. func (s Scanner) Keywords() []string { @@ -66,54 +75,16 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result } if verify { - req, err := http.NewRequestWithContext(ctx, "GET", "https://app.launchdarkly.com/api/v2/caller-identity", nil) - if err != nil { - continue - } - client := s.client - if client == nil { - client = defaultClient - } - req.Header.Add("Authorization", resMatch) - res, err := client.Do(req) - if err == nil { - defer res.Body.Close() - if res.StatusCode >= 200 && res.StatusCode < 300 { - s1.Verified = true - var callerIdentity callerIdentity - if err := json.NewDecoder(res.Body).Decode(&callerIdentity); err == nil { // no error in parsing - s1.ExtraData["type"] = callerIdentity.TokenKind - s1.ExtraData["account_id"] = callerIdentity.AccountId - s1.ExtraData["environment_id"] = callerIdentity.EnvironmentId - s1.ExtraData["project_id"] = callerIdentity.ProjectId - s1.ExtraData["environment_name"] = callerIdentity.EnvironmentName - s1.ExtraData["project_name"] = callerIdentity.ProjectName - s1.ExtraData["auth_kind"] = callerIdentity.AuthKind - s1.ExtraData["token_kind"] = callerIdentity.TokenKind - s1.ExtraData["client_id"] = callerIdentity.ClientID - s1.ExtraData["token_name"] = callerIdentity.TokenName - s1.ExtraData["member_id"] = callerIdentity.MemberId - if callerIdentity.TokenKind == "auth" { - if callerIdentity.ServiceToken { - s1.ExtraData["token_type"] = "service" - } else { - s1.ExtraData["token_type"] = "personal" - } - } - } + extraData, isVerified, verificationErr := verifyLaunchDarklyKey(ctx, s.getClient(), resMatch) + s1.Verified = isVerified + s1.SetVerificationError(verificationErr) + s1.ExtraData = extraData - s1.AnalysisInfo = map[string]string{ - "key": resMatch, - } - - } else if res.StatusCode == 401 { - // 401 is expected for an invalid token, so there is nothing to do here. - } else { - err = fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) - s1.SetVerificationError(err, resMatch) + // only api keys can be analyzed + if strings.HasPrefix(resMatch, "api-") { + s1.AnalysisInfo = map[string]string{ + "key": resMatch, } - } else { - s1.SetVerificationError(err, resMatch) } } @@ -130,3 +101,57 @@ func (s Scanner) Type() detectorspb.DetectorType { func (s Scanner) Description() string { return "LaunchDarkly is a feature management platform that allows teams to control the visibility of features to users. LaunchDarkly API keys can be used to access and modify feature flags and other resources within a LaunchDarkly account." } + +func verifyLaunchDarklyKey(ctx context.Context, client *http.Client, key string) (map[string]string, bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://app.launchdarkly.com/api/v2/caller-identity", http.NoBody) + if err != nil { + return nil, false, err + } + + req.Header.Add("Authorization", key) + + resp, err := client.Do(req) + if err != nil { + return nil, false, err + } + + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + switch resp.StatusCode { + case http.StatusOK: + var callerIdentity callerIdentity + var extraData = make(map[string]string) + + if err := json.NewDecoder(resp.Body).Decode(&callerIdentity); err != nil { + return nil, false, err + } + + extraData["type"] = callerIdentity.AccountId + extraData["account"] = callerIdentity.AccountId + extraData["environment_id"] = callerIdentity.EnvironmentId + extraData["project_id"] = callerIdentity.ProjectId + extraData["environment_name"] = callerIdentity.EnvironmentName + extraData["project_name"] = callerIdentity.ProjectName + extraData["auth_kind"] = callerIdentity.AuthKind + extraData["token_kind"] = callerIdentity.TokenKind + extraData["client_id"] = callerIdentity.ClientID + extraData["token_name"] = callerIdentity.TokenName + extraData["member_id"] = callerIdentity.MemberId + if callerIdentity.TokenKind == "auth" { + if callerIdentity.ServiceToken { + extraData["token_type"] = "service" + } else { + extraData["token_type"] = "personal" + } + } + + return extraData, true, nil + case http.StatusUnauthorized: + return nil, false, nil + default: + return nil, false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } +} diff --git a/pkg/detectors/launchdarkly/launchdarkly_integration_test.go b/pkg/detectors/launchdarkly/launchdarkly_integration_test.go index a8500fb59..1f840b97e 100644 --- a/pkg/detectors/launchdarkly/launchdarkly_integration_test.go +++ b/pkg/detectors/launchdarkly/launchdarkly_integration_test.go @@ -129,6 +129,7 @@ func TestLaunchDarkly_FromChunk(t *testing.T) { t.Fatalf("no raw secret present: \n %+v", got[i]) } got[i].Raw = nil + got[i].AnalysisInfo = nil // Do we expect to be comparing the ExtraData? got[i].ExtraData = nil diff --git a/pkg/detectors/launchdarkly/launchdarkly_test.go b/pkg/detectors/launchdarkly/launchdarkly_test.go index 95824e20d..1fecdd49d 100644 --- a/pkg/detectors/launchdarkly/launchdarkly_test.go +++ b/pkg/detectors/launchdarkly/launchdarkly_test.go @@ -2,7 +2,6 @@ package launchdarkly import ( "context" - "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -11,12 +10,6 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" ) -var ( - validPattern = "sdk-5gykyl1b-wgrq-41lc-z6q1-h7zn5fdlcybw" - invalidPattern = "sdk-5gykyl1b-wgrq-41lc-z6q1-h7zn5fdlcyb" - keyword = "launchdarkly" -) - func TestLaunchDarkly_Pattern(t *testing.T) { d := Scanner{} ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) @@ -26,13 +19,23 @@ func TestLaunchDarkly_Pattern(t *testing.T) { want []string }{ { - name: "valid pattern - with keyword launchdarkly", - input: fmt.Sprintf("%s token = '%s'", keyword, validPattern), - want: []string{validPattern}, + name: "valid pattern - launchdarkly api key", + input: `api-5gykyl1b-wgrq-41lc-z6q1-h7zn5fdlcybw`, + want: []string{"api-5gykyl1b-wgrq-41lc-z6q1-h7zn5fdlcybw"}, }, { - name: "invalid pattern", - input: fmt.Sprintf("%s = '%s'", keyword, invalidPattern), + name: "valid pattern - launchdarkly sdk key", + input: `sdk-5gykyl1b-wgrq-41lc-z6q1-h7zn5fdlcybw`, + want: []string{"sdk-5gykyl1b-wgrq-41lc-z6q1-h7zn5fdlcybw"}, + }, + { + name: "invalid pattern - invalid length", + input: `sdk-5gykyl1b-wgrq-41lc-z6q1-h7zn5fdlcyb`, + want: []string{}, + }, + { + name: "invalid pattern - invalid character", + input: `api-5Gykyl1b-Wgrq-41lC-z6q1-h7zn5fdlcybw`, want: []string{}, }, }