Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 12 additions & 0 deletions pkg/sources/postman/postman.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ .
for _, workspaceID := range s.conn.Workspaces {
w, err := s.client.GetWorkspace(ctx, workspaceID)
if err != nil {
if errors.Is(err, errAbortScanDueToAPIRateLimit) {
return err
}
// Log and move on, because sometimes the Postman API seems to give us workspace IDs
// that we don't have access to, so we don't want to kill the scan because of it.
ctx.Logger().Error(err, "error getting workspace %s", workspaceID)
Expand All @@ -206,6 +209,9 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, _ .

collection, err := s.client.GetCollection(ctx, collectionID)
if err != nil {
if errors.Is(err, errAbortScanDueToAPIRateLimit) {
return err
}
// Log and move on, because sometimes the Postman API seems to give us collection IDs
// that we don't have access to, so we don't want to kill the scan because of it.
ctx.Logger().Error(err, "error getting collection %s", collectionID)
Expand Down Expand Up @@ -279,6 +285,9 @@ func (s *Source) scanWorkspace(ctx context.Context, chunksChan chan *sources.Chu
for _, envID := range workspace.Environments {
envVars, err := s.client.GetEnvironmentVariables(ctx, envID.Uid)
if err != nil {
if errors.Is(err, errAbortScanDueToAPIRateLimit) {
return err
}
ctx.Logger().Error(err, "could not get env variables", "environment_uuid", envID.Uid)
continue
}
Expand Down Expand Up @@ -316,6 +325,9 @@ func (s *Source) scanWorkspace(ctx context.Context, chunksChan chan *sources.Chu
}
collection, err := s.client.GetCollection(ctx, collectionID.Uid)
if err != nil {
if errors.Is(err, errAbortScanDueToAPIRateLimit) {
return err
}
// Log and move on, because sometimes the Postman API seems to give us collection IDs
// that we don't have access to, so we don't want to kill the scan because of it.
ctx.Logger().Error(err, "error getting collection %s", collectionID)
Expand Down
81 changes: 60 additions & 21 deletions pkg/sources/postman/postman_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ import (
)

const (
WORKSPACE_URL = "https://api.getpostman.com/workspaces/%s"
ENVIRONMENTS_URL = "https://api.getpostman.com/environments/%s"
COLLECTIONS_URL = "https://api.getpostman.com/collections/%s"
userAgent = "PostmanRuntime/7.26.8"
defaultContentType = "*"
WORKSPACE_URL = "https://api.getpostman.com/workspaces/%s"
ENVIRONMENTS_URL = "https://api.getpostman.com/environments/%s"
COLLECTIONS_URL = "https://api.getpostman.com/collections/%s"
userAgent = "PostmanRuntime/7.26.8"
defaultContentType = "*"
abortScanAPIReqLimitThreshold = 0.8 // Abort scan if 80% of monthly api request limit is reached
)

var errAbortScanDueToAPIRateLimit = fmt.Errorf("aborting scan due to Postman API monthly requests limit being used over %f%%", abortScanAPIReqLimitThreshold*100)

type Workspace struct {
Id string `json:"id"`
Name string `json:"name"`
Expand Down Expand Up @@ -284,22 +287,9 @@ func (c *Client) getPostmanResponseBodyBytes(ctx trContext.Context, urlString st

c.Metrics.apiRequests.WithLabelValues(urlString).Inc()

rateLimitRemainingMonthValue := resp.Header.Get("RateLimit-Remaining-Month")
if rateLimitRemainingMonthValue == "" {
rateLimitRemainingMonthValue = resp.Header.Get("X-RateLimit-Remaining-Month")
}

if rateLimitRemainingMonthValue != "" {
rateLimitRemainingMonth, err := strconv.Atoi(rateLimitRemainingMonthValue)
if err != nil {
ctx.Logger().Error(err, "Couldn't convert RateLimit-Remaining-Month to an int",
"header_value", rateLimitRemainingMonthValue,
)
} else {
c.Metrics.apiMonthlyRequestsRemaining.WithLabelValues().Set(
float64(rateLimitRemainingMonth),
)
}
err = c.handleRateLimits(ctx, resp)
if err != nil {
return nil, err
}

body, err := io.ReadAll(resp.Body)
Expand All @@ -315,6 +305,55 @@ func (c *Client) getPostmanResponseBodyBytes(ctx trContext.Context, urlString st
return body, nil
}

// handleRateLimits processes the rate limit headers from the Postman API response
// and updates the client's metrics accordingly. If the monthly rate limit usage exceeds
// the set threshold, an error is returned to abort further processing.
func (c *Client) handleRateLimits(ctx trContext.Context, resp *http.Response) error {
rateLimitRemainingMonthValue := resp.Header.Get("RateLimit-Remaining-Month")
if rateLimitRemainingMonthValue == "" {
rateLimitRemainingMonthValue = resp.Header.Get("X-RateLimit-Remaining-Month")
}
if rateLimitRemainingMonthValue == "" {
ctx.Logger().V(2).Info("RateLimit-Remaining-Month header not found in response")
return nil
}

rateLimitRemainingMonth, err := strconv.Atoi(rateLimitRemainingMonthValue)
if err != nil {
ctx.Logger().Error(err, "Couldn't convert RateLimit-Remaining-Month to an int",
"header_value", rateLimitRemainingMonthValue,
)
return nil
}
c.Metrics.apiMonthlyRequestsRemaining.WithLabelValues().Set(
float64(rateLimitRemainingMonth),
)

rateLimitTotalMonthValue := resp.Header.Get("RateLimit-Limit-Month")
if rateLimitTotalMonthValue == "" {
rateLimitTotalMonthValue = resp.Header.Get("X-RateLimit-Limit-Month")
}
if rateLimitTotalMonthValue == "" {
ctx.Logger().V(2).Info("RateLimit-Limit-Month header not found in response")
return nil
}
rateLimitTotalMonth, err := strconv.Atoi(rateLimitTotalMonthValue)
if err != nil {
ctx.Logger().Error(err, "Couldn't convert RateLimit-Limit-Month to an int",
"header_value", rateLimitTotalMonthValue,
)
return nil
}

// failsafe to abandon scan if we are over the threshold of monthly API requests used
percentageUsed := float64(rateLimitTotalMonth-rateLimitRemainingMonth) / float64(rateLimitTotalMonth)
if percentageUsed > abortScanAPIReqLimitThreshold {
return errAbortScanDueToAPIRateLimit
}

return nil
}

// EnumerateWorkspaces returns the workspaces for a given user (both private, public, team and personal).
// Consider adding additional flags to support filtering.
func (c *Client) EnumerateWorkspaces(ctx trContext.Context) ([]Workspace, error) {
Expand Down
65 changes: 65 additions & 0 deletions pkg/sources/postman/postman_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"
"time"

"github.com/go-errors/errors"
"github.com/stretchr/testify/assert"

"github.com/trufflesecurity/trufflehog/v3/pkg/context"
Expand Down Expand Up @@ -734,3 +735,67 @@ func TestSource_ResponseID(t *testing.T) {
}

}

func TestSource_AbortScanRateLimitCrossedThreshold(t *testing.T) {
defer gock.Off()
// Mock the API response for getting collection
gock.New("https://api.getpostman.com").
Get("/collections/1234-abc1").
Reply(200).
AddHeader("X-RateLimit-Remaining-Month", "19").
AddHeader("X-RateLimit-Limit-Month", "100").
BodyString(`{"collection":{"info":{"_postman_id":"abc1","name":"test-collection-1","schema":"https://schema.postman.com/json/collection/v2.1.0/collection.json",
"updatedAt":"2025-03-21T17:39:25.000Z","createdAt":"2025-03-21T17:37:13.000Z","lastUpdatedBy":"1234","uid":"1234-abc1"},
"item":[]}}`)

ctx := context.Background()
s, conn := createTestSource(&sourcespb.Postman{
Credential: &sourcespb.Postman_Token{
Token: "super-secret-token",
},
})

err := s.Init(ctx, "test - postman", 0, 1, false, conn, 1)
if err != nil {
t.Fatalf("init error: %v", err)
}
gock.InterceptClient(s.client.HTTPClient)
defer gock.RestoreClient(s.client.HTTPClient)

_, err = s.client.GetCollection(ctx, "1234-abc1")
if !errors.Is(err, errAbortScanDueToAPIRateLimit) {
t.Errorf("expected abort scan error due to rate limit, got: %v", err)
}
}

func TestSource_AbortScanRateLimitBelowThreshold(t *testing.T) {
defer gock.Off()
// Mock the API response for getting collection
gock.New("https://api.getpostman.com").
Get("/collections/1234-abc1").
Reply(200).
AddHeader("X-RateLimit-Remaining-Month", "90").
AddHeader("X-RateLimit-Limit-Month", "100").
BodyString(`{"collection":{"info":{"_postman_id":"abc1","name":"test-collection-1","schema":"https://schema.postman.com/json/collection/v2.1.0/collection.json",
"updatedAt":"2025-03-21T17:39:25.000Z","createdAt":"2025-03-21T17:37:13.000Z","lastUpdatedBy":"1234","uid":"1234-abc1"},
"item":[]}}`)

ctx := context.Background()
s, conn := createTestSource(&sourcespb.Postman{
Credential: &sourcespb.Postman_Token{
Token: "super-secret-token",
},
})

err := s.Init(ctx, "test - postman", 0, 1, false, conn, 1)
if err != nil {
t.Fatalf("init error: %v", err)
}
gock.InterceptClient(s.client.HTTPClient)
defer gock.RestoreClient(s.client.HTTPClient)

_, err = s.client.GetCollection(ctx, "1234-abc1")
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
}
Loading