Updated LaunchDarkly Analyzer and Detector (#4178)

* updated launchdarkly analyzer and detector

* updated launchdarkly analyzer test

* fixed linter

* remove extradata nil check
This commit is contained in:
Kashif Khan
2025-05-27 12:56:30 -05:00
committed by GitHub
parent 92e9157267
commit c3f0e1cbd2
6 changed files with 114 additions and 63 deletions
@@ -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-")
}
@@ -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,
@@ -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": "[email protected]",
"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",
+71 -46
View File
@@ -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)
}
}
@@ -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
+15 -12
View File
@@ -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{},
},
}