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
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ var (

defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses

// Reference tokens are base64-encoded strings starting with "reftkn:01|<version>:<expiry>:<random>"
// The base64 encoding of "reftkn" is "cmVmdGtu", total length is always 64 characters
tokenPat = regexp.MustCompile(`\b(cmVmdGtu[A-Za-z0-9]{56})\b`)
// Reference tokens are base64-encoded strings of "reftkn:01:<expiry>:<random>"
// Fixed format: prefix "cmVmdGtuOj" (base64 of "reftkn:") + exactly 54 base64 chars = 64 total
tokenPat = regexp.MustCompile(`\b(cmVmdGtuOj[a-zA-Z0-9]{54})\b`)
urlPat = regexp.MustCompile(`\b([A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]\.jfrog\.io)`)

invalidHosts = simple.NewCache[struct{}]()
Expand All @@ -41,7 +41,7 @@ func (Scanner) CloudEndpoint() string { return "" }

// Keywords are used for efficiently pre-filtering chunks.
func (s Scanner) Keywords() []string {
return []string{"cmVmdGtu"}
return []string{"cmVmdGtuOj"}
}

func (s Scanner) getClient() *http.Client {
Expand Down Expand Up @@ -75,6 +75,16 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
}

for token := range uniqueTokens {
if len(uniqueUrls) == 0 {
// No domain found in this chunk — emit unverified so the token is not silently dropped.
results = append(results, detectors.Result{
DetectorType: detector_typepb.DetectorType_ArtifactoryReferenceToken,
Raw: []byte(token),
SecretParts: map[string]string{"token": token},
})
continue
}

for url := range uniqueUrls {
if invalidHosts.Exists(url) {
continue
Expand Down
105 changes: 68 additions & 37 deletions pkg/detectors/grafanaserviceaccount/grafanaserviceaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package grafanaserviceaccount
import (
"context"
"fmt"
"io"
"net/http"
"strings"

regexp "github.com/wasilibs/go-re2"

"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
Expand All @@ -21,65 +23,67 @@ type Scanner struct {
var _ detectors.Detector = (*Scanner)(nil)

var (
defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
keyPat = regexp.MustCompile(`\b(glsa_[0-9a-zA-Z_]{41})\b`)
defaultClient = common.SaneHttpClient()

// Pattern: glsa_ + 32 alphanumeric chars + _ + 8 hex chars = total 46 chars
keyPat = regexp.MustCompile(`\b(glsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8})\b`)
domainPat = regexp.MustCompile(`\b([a-zA-Z0-9-]+\.grafana\.net)\b`)
)

// Keywords are used for efficiently pre-filtering chunks.
// Use identifiers in the secret preferably, or the provider name.
func (s Scanner) Keywords() []string {
return []string{"glsa_"}
}

// FromData will find and optionally verify Grafanaserviceaccount secrets in a given set of bytes.
func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}

// FromData will find and optionally verify GrafanaServiceAccount secrets in a given set of bytes.
// If a grafana.net domain is found in the same chunk it is used for verification.
// If no domain is found the token is still emitted (unverified) so it is not silently dropped.
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)

keyMatches := keyPat.FindAllStringSubmatch(dataStr, -1)
domainMatches := domainPat.FindAllStringSubmatch(dataStr, -1)
var uniqueKeys = make(map[string]struct{})
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
uniqueKeys[strings.TrimSpace(match[1])] = struct{}{}
}

for _, match := range keyMatches {
key := strings.TrimSpace(match[1])
var uniqueDomains = make(map[string]struct{})
for _, match := range domainPat.FindAllStringSubmatch(dataStr, -1) {
uniqueDomains[strings.TrimSpace(match[1])] = struct{}{}
}

for _, domainMatch := range domainMatches {
domainRes := strings.TrimSpace(domainMatch[1])
for key := range uniqueKeys {
if len(uniqueDomains) == 0 {
// No domain found in this chunk — emit unverified so the token is not silently dropped.
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_GrafanaServiceAccount,
Raw: []byte(key),
SecretParts: map[string]string{"key": key},
}
results = append(results, s1)
continue
}

for domain := range uniqueDomains {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_GrafanaServiceAccount,
Raw: []byte(key),
RawV2: []byte(domain + ":" + key),
SecretParts: map[string]string{
"domain": domainRes,
"key": key,
"domain": domain,
},
RawV2: []byte(fmt.Sprintf("%s:%s", domainRes, key)),
}

if verify {
client := s.client
if client == nil {
client = defaultClient
}
req, err := http.NewRequestWithContext(ctx, "GET", "https://"+domainRes+"/api/access-control/user/permissions", nil)
if err != nil {
continue
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", key))
res, err := client.Do(req)
if err == nil {
defer res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
s1.Verified = true
} else if res.StatusCode == 401 {
// The secret is determinately not verified (nothing to do)
} else {
err = fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
s1.SetVerificationError(err, key)
}
} else {
s1.SetVerificationError(err, key)
}
isVerified, verificationErr := verifyGrafanaServiceAccount(ctx, s.getClient(), domain, key)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, key)
}

results = append(results, s1)
Expand All @@ -89,6 +93,33 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
return results, nil
}

func verifyGrafanaServiceAccount(ctx context.Context, client *http.Client, domain, key string) (bool, error) {
// https://grafana.com/docs/grafana/latest/developers/http_api/access_control/
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+domain+"/api/access-control/user/permissions", http.NoBody)
if err != nil {
return false, err
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", key))

resp, err := client.Do(req)
if err != nil {
return false, err
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()

switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected HTTP response status %d", resp.StatusCode)
}
}

func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_GrafanaServiceAccount
}
Expand Down
124 changes: 78 additions & 46 deletions pkg/detectors/shopify/shopify.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ package shopify
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"

regexp "github.com/wasilibs/go-re2"

"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)

type Scanner struct {
client *http.Client
detectors.DefaultMultiPartCredentialProvider
}

Expand All @@ -21,80 +25,108 @@ var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil)

var (
client = detectors.DetectorHttpClientWithNoLocalAddresses
defaultClient = common.SaneHttpClient()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSRF protection removed from user-controlled domain requests

Medium Severity

The defaultClient was changed from detectors.DetectorHttpClientWithNoLocalAddresses to common.SaneHttpClient(). Both the Shopify and Grafana detectors make verification HTTP requests to domains extracted from scanned data (user-controlled input). The previous client included WithNoLocalIP() protection that blocks connections to loopback, link-local, and private IP addresses, preventing SSRF. common.SaneHttpClient() lacks this safeguard. While the domain regexes constrain targets to *.myshopify.com and *.grafana.net, this still removes an intentional defense-in-depth layer against DNS rebinding or similar attacks.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ba42427. Configure here.


// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
keyPat = regexp.MustCompile(`\b(shppa_|shpat_)([0-9A-Fa-f]{32})\b`)
domainPat = regexp.MustCompile(`[a-zA-Z0-9-]+\.myshopify\.com`)
// Covers: shpca_, shpat_, shptka_, shppa_ (custom app, private app, token app, partner app)
keyPat = regexp.MustCompile(`\b(shp(?:ca|at|tka|pa)_[a-f0-9]{32})\b`)
domainPat = regexp.MustCompile(`\b([a-zA-Z0-9-]+\.myshopify\.com)\b`)
)

// Keywords are used for efficiently pre-filtering chunks.
// Use identifiers in the secret preferably, or the provider name.
func (s Scanner) Keywords() []string {
return []string{"shppa_", "shpat_"}
return []string{"shpca_", "shpat_", "shptka_", "shppa_"}
}

func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}

// FromData will find and optionally verify Shopify secrets in a given set of bytes.
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)

keyMatches := keyPat.FindAllString(dataStr, -1)
domainMatches := domainPat.FindAllString(dataStr, -1)

for _, match := range keyMatches {
key := strings.TrimSpace(match)
uniqueKeys := make(map[string]struct{})
for _, match := range keyPat.FindAllString(dataStr, -1) {
uniqueKeys[strings.TrimSpace(match)] = struct{}{}
}

for _, domainMatch := range domainMatches {
domainRes := strings.TrimSpace(domainMatch)
uniqueDomains := make(map[string]struct{})
for _, match := range domainPat.FindAllString(dataStr, -1) {
uniqueDomains[strings.TrimSpace(match)] = struct{}{}
}

for key := range uniqueKeys {
if len(uniqueDomains) == 0 {
// No domain found — emit unverified so token is not silently dropped.
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_Shopify,
Redacted: domainRes,
Raw: []byte(key + domainRes),
Raw: []byte(key),
SecretParts: map[string]string{"key": key},
}
s1.SetPrimarySecretValue(key)
results = append(results, s1)
continue
}

// set key as the primary secret for engine to find the line number
for domain := range uniqueDomains {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_Shopify,
Redacted: domain,
Raw: []byte(key + domain),
SecretParts: map[string]string{"key": key, "store_url": domain},
}
s1.SetPrimarySecretValue(key)

if verify {
req, err := http.NewRequestWithContext(ctx, "GET", "https://"+domainRes+"/admin/oauth/access_scopes.json", nil)
if err != nil {
continue
}
req.Header.Add("X-Shopify-Access-Token", key)
res, err := client.Do(req)
if err == nil {
if res.StatusCode >= 200 && res.StatusCode < 300 {
shopifyTokenAccessScopes := shopifyTokenAccessScopes{}
err := json.NewDecoder(res.Body).Decode(&shopifyTokenAccessScopes)
if err == nil {
var handleArray []string
for _, handle := range shopifyTokenAccessScopes.AccessScopes {
handleArray = append(handleArray, handle.Handle)

}
s1.Verified = true
s1.ExtraData = map[string]string{
"access_scopes": strings.Join(handleArray, ","),
}
s1.SecretParts = map[string]string{
"key": key,
"store_url": domainRes,
}
}
res.Body.Close()
}
isVerified, extraData, verificationErr := verifyShopify(ctx, s.getClient(), domain, key)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, key)
if isVerified {
s1.ExtraData = extraData
}
}

results = append(results, s1)

}

}

return results, nil
}

func verifyShopify(ctx context.Context, client *http.Client, domain, key string) (bool, map[string]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+domain+"/admin/oauth/access_scopes.json", http.NoBody)
if err != nil {
return false, nil, err
}
req.Header.Add("X-Shopify-Access-Token", key)

res, err := client.Do(req)
if err != nil {
return false, nil, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()

switch res.StatusCode {
case http.StatusOK:
var scopes shopifyTokenAccessScopes
if err := json.NewDecoder(res.Body).Decode(&scopes); err != nil {
return false, nil, err
}
var handles []string
for _, s := range scopes.AccessScopes {
handles = append(handles, s.Handle)
}
return true, map[string]string{"access_scopes": strings.Join(handles, ",")}, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil, nil
default:
return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
}
}

func (s Scanner) IsFalsePositive(_ detectors.Result) (bool, string) {
Expand Down
Loading