Files
trufflehog/pkg/analyzer/analyzers/launchdarkly/requests.go
2025-09-04 14:09:39 +05:00

742 lines
20 KiB
Go

package launchdarkly
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
)
const defaultTimeout = 5 * time.Second
var (
baseURL = "https://app.launchdarkly.com/api"
endpoints = map[string]string{
// user information APIs
"callerIdentity": "/v2/caller-identity",
"getToken": "/v2/tokens/%s", // require token id
"getRole": "/v2/roles/%s", // require role id
// resource APIs
applicationKey: "/v2/applications",
repositoryKey: "/v2/code-refs/repositories",
projectKey: "/v2/projects",
environmentKey: "/v2/projects/%s/environments", // require project key
featureFlagsKey: "/v2/flags/%s", // require project key
experimentKey: "/v2/projects/%s/environments/%s/experiments", // require project key and env key
holdoutsKey: "/v2/projects/%s/environments/%s/holdouts", // require project key and env key
membersKey: "/v2/members",
destinationsKey: "/v2/destinations",
templatesKey: "/v2/templates",
teamsKey: "/v2/teams",
webhooksKey: "/v2/webhooks",
/*
TODO:
release pipelines: https://launchdarkly.com/docs/api/release-pipelines-beta/get-all-release-pipelines (Beta)
insight deployments: https://launchdarkly.com/docs/api/insights-deployments-beta/get-deployments (Beta)
delivery configuration: https://launchdarkly.com/docs/api/integration-delivery-configurations-beta/get-integration-delivery-configuration-by-environment (Beta)
metrics: https://launchdarkly.com/docs/api/metrics-beta/get-metric-groups (Beta)
*/
}
)
// applicationsResponse is the response of /v2/applications API
type applicationsResponse struct {
Items []struct {
Key string `json:"key"`
Name string `json:"name"`
Kind string `json:"kind"`
Maintainer struct {
Email string `json:"email"`
} `json:"_maintainer"`
} `json:"items"`
}
// repositoriesResponse is the response of /v2/code-refs/repositories API
type repositoriesResponse struct {
Items []struct {
Name string `json:"name"`
Type string `json:"type"`
DefaultBranch string `json:"defaultBranch"`
SourceLink string `json:"sourceLink"`
Version int `json:"version"`
} `json:"items"`
}
// projectsResponse is the response of /v2/projects API
type projectsResponse struct {
Items []struct {
ID string `json:"_id"`
Key string `json:"key"`
Name string `json:"name"`
} `json:"items"`
}
// featureFlagsResponse is the response of /v2/flags/<project_id> API
type featureFlagsResponse struct {
Items []struct {
Key string `json:"key"`
Name string `json:"name"`
Kind string `json:"kind"`
} `json:"items"`
}
// environmentsResponse is the response of /v2/projects/<proj_key>/environments API
type environmentsResponse struct {
Items []struct {
ID string `json:"_id"`
Key string `json:"key"`
Name string `json:"name"`
} `json:"items"`
}
// experimentResponse is the response of /v2/projects/<proj_id>/env/<env_id>/experiments
type experimentResponse struct {
Items []struct {
ID string `json:"_id"`
Key string `json:"key"`
Name string `json:"name"`
MaintainerID string `json:"_maintainerId"`
} `json:"items"`
}
// membersResponse is the response of /v2/members API
type membersResponse struct {
Items []struct {
ID string `json:"_id"`
Role string `json:"role"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
} `json:"items"`
}
// holdoutsResponse is the response of /v2/projects/<project_id>/environments/<env_id>/holdouts API
type holdoutsResponse struct {
Items []struct {
ID string `json:"_id"`
Name string `json:"name"`
Key string `json:"key"`
Status string `json:"status"`
} `json:"items"`
}
// destinationsResponse is the response of /v2/destinations API
type destinationsResponse struct {
Items []struct {
ID string `json:"_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Version int `json:"version"`
} `json:"items"`
}
// templatesResponse is the response of /v2/templates API
type templatesResponse struct {
Items []struct {
ID string `json:"_id"`
Key string `json:"_key"`
Name string `json:"name"`
} `json:"items"`
}
// teamsResponse is the response of /v2/teams API
type teamsResponse struct {
Items []struct {
Key string `json:"key"`
Name string `json:"name"`
Roles struct {
TotalCount int `json:"totalCount"`
} `json:"roles"`
Members struct {
TotalCount int `json:"totalCount"`
} `json:"members"`
Projects struct {
TotalCount int `json:"totalCount"`
} `json:"projects"`
} `json:"items"`
}
// webhooksResponse is the response of /v2/webhooks API
type webhooksResponse struct {
Items []struct {
ID string `json:"_id"`
Name string `json:"name"`
Url string `json:"url"`
} `json:"items"`
}
// makeLaunchDarklyRequest send the HTTP GET API request to passed url with passed token and return response body and status code
func makeLaunchDarklyRequest(client *http.Client, endpoint, token string) ([]byte, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
// create request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+endpoint, http.NoBody)
if err != nil {
return nil, 0, err
}
// add required keys in the header
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
responseBodyByte, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, err
}
return responseBodyByte, resp.StatusCode, nil
}
func CaptureResources(client *http.Client, token string, secretInfo *SecretInfo) error {
var (
wg sync.WaitGroup
errAggWg sync.WaitGroup
aggregatedErrs = make([]error, 0)
errChan = make(chan error, 1)
)
errAggWg.Add(1)
go func() {
defer errAggWg.Done()
for err := range errChan {
aggregatedErrs = append(aggregatedErrs, err)
}
}()
// helper to launch tasks concurrently.
launchTask := func(task func() error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := task(); err != nil {
errChan <- err
}
}()
}
// capture top-level resources
launchTask(func() error { return captureApplications(client, token, secretInfo) })
launchTask(func() error { return captureRepositories(client, token, secretInfo) })
// capture projects
launchTask(func() error {
if err := captureProjects(client, token, secretInfo); err != nil {
return err
}
// capture project sub resources
projects := secretInfo.listResourceByType(projectKey)
for _, proj := range projects {
launchTask(func() error { return captureProjectFeatureFlags(client, token, proj, secretInfo) })
launchTask(func() error { return captureProjectEnv(client, token, proj, secretInfo) })
}
return nil
})
launchTask(func() error { return captureMembers(client, token, secretInfo) })
launchTask(func() error { return captureDestinations(client, token, secretInfo) })
launchTask(func() error { return captureTemplates(client, token, secretInfo) })
launchTask(func() error { return captureTeams(client, token, secretInfo) })
launchTask(func() error { return captureWebhooks(client, token, secretInfo) })
wg.Wait()
close(errChan)
errAggWg.Wait()
if len(aggregatedErrs) > 0 {
return errors.Join(aggregatedErrs...)
}
return nil
}
// docs: https://launchdarkly.com/docs/api/applications-beta/get-applications
func captureApplications(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[applicationKey], token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var applications = applicationsResponse{}
if err := json.Unmarshal(response, &applications); err != nil {
return err
}
for _, application := range applications.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/app/%s", application.Key),
Name: application.Name,
Type: applicationKey,
MetaData: map[string]string{
"Maintainer Email": application.Maintainer.Email,
"Kind": application.Kind,
MetadataKey: application.Key,
},
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/code-references/get-repositories
func captureRepositories(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[repositoryKey], token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var repositories = repositoriesResponse{}
if err := json.Unmarshal(response, &repositories); err != nil {
return err
}
for _, repository := range repositories.Items {
resource := Resource{
ID: fmt.Sprintf("%s/repo/%s/%d", repository.Type, repository.Name, repository.Version), // no unique id exist, so we make one
Name: repository.Name,
Type: repositoryKey,
MetaData: map[string]string{
"Default branch": repository.DefaultBranch,
"Version": strconv.Itoa(repository.Version),
"Source link": repository.SourceLink,
MetadataKey: repositoryKey,
},
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/projects/get-projects
func captureProjects(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[projectKey], token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var projects = projectsResponse{}
if err := json.Unmarshal(response, &projects); err != nil {
return err
}
for _, project := range projects.Items {
secretInfo.appendResource(Resource{
ID: fmt.Sprintf("launchdarkly/proj/%s", project.ID),
Name: project.Name,
Type: projectKey,
MetaData: map[string]string{
MetadataKey: project.Key,
},
})
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/feature-flags/get-feature-flags
func captureProjectFeatureFlags(client *http.Client, token string, parent Resource, secretInfo *SecretInfo) error {
projectKey, exist := parent.MetaData[MetadataKey]
if !exist {
return errors.New("project key not found")
}
response, statusCode, err := makeLaunchDarklyRequest(client, fmt.Sprintf(endpoints[featureFlagsKey], projectKey), token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var flags = featureFlagsResponse{}
if err := json.Unmarshal(response, &flags); err != nil {
return err
}
for _, flag := range flags.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/proj/%s/flag/%s", projectKey, flag.Key),
Name: flag.Name,
Type: featureFlagsKey,
MetaData: map[string]string{
"Kind": flag.Kind,
},
ParentResource: &parent,
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/environments/get-environments-by-project
func captureProjectEnv(client *http.Client, token string, parent Resource, secretInfo *SecretInfo) error {
projectKey, exist := parent.MetaData[MetadataKey]
if !exist {
return errors.New("project key not found")
}
response, statusCode, err := makeLaunchDarklyRequest(client, fmt.Sprintf(endpoints[environmentKey], projectKey), token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var envs = environmentsResponse{}
if err := json.Unmarshal(response, &envs); err != nil {
return err
}
for _, env := range envs.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/%s/env/%s", projectKey, env.ID),
Name: env.Name,
Type: environmentKey,
MetaData: map[string]string{
MetadataKey: env.Key,
},
ParentResource: &parent,
}
secretInfo.appendResource(resource)
// capture project env child resources
if err := captureProjectEnvExperiments(client, token, projectKey, resource, secretInfo); err != nil {
return err
}
if err := captureProjectHoldouts(client, token, projectKey, resource, secretInfo); err != nil {
return err
}
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/experiments/get-experiments
func captureProjectEnvExperiments(client *http.Client, token string, projectKey string, parent Resource, secretInfo *SecretInfo) error {
envKey, exist := parent.MetaData[MetadataKey]
if !exist {
return errors.New("env key not found")
}
response, statusCode, err := makeLaunchDarklyRequest(client, fmt.Sprintf(endpoints[experimentKey], projectKey, envKey), token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var exps = experimentResponse{}
if err := json.Unmarshal(response, &exps); err != nil {
return err
}
for _, exp := range exps.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/%s/env/%s/exp/%s", projectKey, envKey, exp.ID),
Name: exp.Name,
Type: experimentKey,
MetaData: map[string]string{
MetadataKey: exp.Key,
"Maintiner ID": exp.MaintainerID,
},
ParentResource: &parent,
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound:
return nil
case http.StatusTooManyRequests:
time.Sleep(1 * time.Second)
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/holdouts-beta/get-all-holdouts
func captureProjectHoldouts(client *http.Client, token string, projectKey string, parent Resource, secretInfo *SecretInfo) error {
envKey, exist := parent.MetaData[MetadataKey]
if !exist {
return errors.New("env key not found")
}
response, statusCode, err := makeLaunchDarklyRequest(client, fmt.Sprintf(endpoints[holdoutsKey], projectKey, envKey), token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var holdouts = holdoutsResponse{}
if err := json.Unmarshal(response, &holdouts); err != nil {
return err
}
for _, holdout := range holdouts.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/%s/env/%s/holdout/%s", projectKey, envKey, holdout.ID),
Name: holdout.Name,
Type: holdoutsKey,
MetaData: map[string]string{
"Status": holdout.Status,
holdoutsKey: holdout.Key,
},
ParentResource: &parent,
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/account-members/get-members
func captureMembers(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[membersKey], token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var members = membersResponse{}
if err := json.Unmarshal(response, &members); err != nil {
return err
}
for _, member := range members.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/member/%s", member.ID),
Name: member.FirstName + " " + member.LastName,
Type: membersKey,
MetaData: map[string]string{
"Role": member.Role,
"Email": member.Email,
},
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/data-export-destinations/get-destinations
func captureDestinations(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[destinationsKey], token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var destinations = destinationsResponse{}
if err := json.Unmarshal(response, &destinations); err != nil {
return err
}
for _, destination := range destinations.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/destination/%s", destination.ID),
Name: destination.Name,
Type: destinationsKey,
MetaData: map[string]string{
"Kind": destination.Kind,
"Version": strconv.Itoa(destination.Version),
},
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/workflow-templates/get-workflow-templates
func captureTemplates(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[templatesKey], token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var templates = templatesResponse{}
if err := json.Unmarshal(response, &templates); err != nil {
return err
}
for _, template := range templates.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/templates/%s", template.ID),
Name: template.Name,
Type: templatesKey,
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/teams/get-teams
func captureTeams(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[teamsKey], token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var teams = teamsResponse{}
if err := json.Unmarshal(response, &teams); err != nil {
return err
}
for _, team := range teams.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/teams/%s", team.Key),
Name: team.Name,
Type: teamsKey,
MetaData: map[string]string{
"Total Roles Count": strconv.Itoa(team.Roles.TotalCount),
"Total Memvers Count": strconv.Itoa(team.Members.TotalCount),
"Total Projects Count": strconv.Itoa(team.Projects.TotalCount),
},
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}
// docs: https://launchdarkly.com/docs/api/webhooks/get-all-webhooks
func captureWebhooks(client *http.Client, token string, secretInfo *SecretInfo) error {
response, statusCode, err := makeLaunchDarklyRequest(client, endpoints[webhooksKey], token)
if err != nil {
return err
}
switch statusCode {
case http.StatusOK:
var webhooks = webhooksResponse{}
if err := json.Unmarshal(response, &webhooks); err != nil {
return err
}
for _, webhook := range webhooks.Items {
resource := Resource{
ID: fmt.Sprintf("launchdarkly/webhooks/%s", webhook.ID),
Name: webhook.Name,
Type: webhooksKey,
}
secretInfo.appendResource(resource)
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil
default:
return fmt.Errorf("unexpected status code: %d", statusCode)
}
}