Skip to content

Commit 735bac0

Browse files
authored
feat(algolia): upgrade detector (#3613)
1 parent 457b129 commit 735bac0

File tree

1 file changed

+85
-73
lines changed

1 file changed

+85
-73
lines changed

pkg/detectors/algoliaadminkey/algoliaadminkey.go

+85-73
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@ package algoliaadminkey
22

33
import (
44
"context"
5-
"fmt"
65
"encoding/json"
6+
"fmt"
77
regexp "github.com/wasilibs/go-re2"
8+
"io"
89
"net/http"
10+
"slices"
911
"strings"
1012

1113
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
1214
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
1315
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
1416
)
1517

16-
type Scanner struct{
18+
type Scanner struct {
1719
detectors.DefaultMultiPartCredentialProvider
1820
}
1921

@@ -24,8 +26,8 @@ var (
2426
client = common.SaneHttpClient()
2527

2628
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
27-
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"algolia", "docsearch", "apiKey"}) + `\b([a-zA-Z0-9]{32})\b`)
2829
idPat = regexp.MustCompile(detectors.PrefixRegex([]string{"algolia", "docsearch", "appId"}) + `\b([A-Z0-9]{10})\b`)
30+
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"algolia", "docsearch", "apiKey"}) + `\b([a-zA-Z0-9]{32})\b`)
2931
)
3032

3133
// Keywords are used for efficiently pre-filtering chunks.
@@ -38,105 +40,115 @@ func (s Scanner) Keywords() []string {
3840
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
3941
dataStr := string(data)
4042

41-
matches := keyPat.FindAllStringSubmatch(dataStr, -1)
42-
idMatches := idPat.FindAllStringSubmatch(dataStr, -1)
43-
44-
for _, match := range matches {
45-
if len(match) != 2 {
46-
continue
43+
// Deduplicate matches.
44+
idMatches := make(map[string]struct{})
45+
for _, match := range idPat.FindAllStringSubmatch(dataStr, -1) {
46+
id := match[1]
47+
if detectors.StringShannonEntropy(id) > 2 {
48+
idMatches[id] = struct{}{}
4749
}
48-
resMatch := strings.TrimSpace(match[1])
49-
for _, idMatch := range idMatches {
50-
if len(idMatch) != 2 {
51-
continue
52-
}
53-
resIdMatch := strings.TrimSpace(idMatch[1])
50+
}
51+
keyMatches := make(map[string]struct{})
52+
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
53+
key := match[1]
54+
if detectors.StringShannonEntropy(key) > 3 {
55+
keyMatches[key] = struct{}{}
56+
}
57+
}
5458

55-
s1 := detectors.Result{
59+
// Test matches.
60+
for key := range keyMatches {
61+
for id := range idMatches {
62+
r := detectors.Result{
5663
DetectorType: detectorspb.DetectorType_AlgoliaAdminKey,
57-
Raw: []byte(resMatch),
58-
RawV2: []byte(resMatch + resIdMatch),
64+
Raw: []byte(key),
65+
RawV2: []byte(id + ":" + key),
5966
}
6067

6168
if verify {
6269
// Verify if the key is a valid Algolia Admin Key.
63-
isVerified, verificationErr := verifyAlgoliaKey(ctx, resIdMatch, resMatch)
64-
65-
// Verify if the key has sensitive permissions, even if it's not an Admin Key.
66-
if !isVerified {
67-
isVerified, verificationErr = verifyAlgoliaKeyACL(ctx, resIdMatch, resMatch)
68-
}
69-
70-
s1.SetVerificationError(verificationErr, resMatch)
71-
s1.Verified = isVerified
70+
isVerified, extraData, verificationErr := verifyMatch(ctx, id, key)
71+
r.Verified = isVerified
72+
r.ExtraData = extraData
73+
r.SetVerificationError(verificationErr, key)
7274
}
7375

74-
results = append(results, s1)
76+
results = append(results, r)
77+
if r.Verified {
78+
break
79+
}
7580
}
7681
}
7782
return results, nil
7883
}
7984

80-
func verifyAlgoliaKey(ctx context.Context, appId, apiKey string) (bool, error) {
81-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+appId+"-dsn.algolia.net/1/keys", nil)
82-
if err != nil {
83-
return false, err
84-
}
85-
86-
req.Header.Add("X-Algolia-Application-Id", appId)
87-
req.Header.Add("X-Algolia-API-Key", apiKey)
88-
89-
res, err := client.Do(req)
90-
if err != nil {
91-
return false, err
92-
}
93-
defer res.Body.Close()
94-
95-
if res.StatusCode == 403 {
96-
return false, nil
97-
} else if res.StatusCode < 200 || res.StatusCode > 299 {
98-
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
99-
}
100-
101-
return true, nil
85+
// https://www.algolia.com/doc/guides/security/api-keys/#access-control-list-acl
86+
var nonSensitivePermissions = map[string]struct{}{
87+
"listIndexes": {},
88+
"search": {},
89+
"settings": {},
10290
}
10391

104-
func verifyAlgoliaKeyACL(ctx context.Context, appId, apiKey string) (bool, error) {
92+
func verifyMatch(ctx context.Context, appId, apiKey string) (bool, map[string]string, error) {
93+
// https://www.algolia.com/doc/rest-api/search/#section/Base-URLs
10594
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+appId+".algolia.net/1/keys/"+apiKey, nil)
10695
if err != nil {
107-
return false, err
96+
return false, nil, err
10897
}
10998

110-
req.Header.Add("X-Algolia-Application-Id", appId)
111-
req.Header.Add("X-Algolia-API-Key", apiKey)
99+
req.Header.Set("X-Algolia-Application-Id", appId)
100+
req.Header.Set("X-Algolia-API-Key", apiKey)
112101

113102
res, err := client.Do(req)
114103
if err != nil {
115-
return false, err
116-
}
117-
defer res.Body.Close()
118-
119-
if res.StatusCode == 403 {
120-
return false, nil
121-
} else if res.StatusCode < 200 || res.StatusCode > 299 {
122-
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
123-
}
124-
125-
var jsonResponse struct {
126-
ACL []string `json:"acl"`
104+
return false, nil, err
127105
}
106+
defer func() {
107+
_, _ = io.Copy(io.Discard, res.Body)
108+
_ = res.Body.Close()
109+
}()
110+
111+
switch res.StatusCode {
112+
case http.StatusOK:
113+
var keyRes keyResponse
114+
if err := json.NewDecoder(res.Body).Decode(&keyRes); err != nil {
115+
return false, nil, err
116+
}
128117

129-
if err := json.NewDecoder(res.Body).Decode(&jsonResponse); err != nil {
130-
return false, err
131-
}
118+
// Check if the key has sensitive permissions, even if it's not an Admin Key.
119+
hasSensitivePerms := false
120+
for _, acl := range keyRes.ACL {
121+
if _, ok := nonSensitivePermissions[acl]; !ok {
122+
hasSensitivePerms = true
123+
break
124+
}
125+
}
126+
if !hasSensitivePerms {
127+
return false, nil, nil
128+
}
132129

133-
for _, acl := range jsonResponse.ACL {
134-
if acl != "search" && acl != "listIndexes" && acl != "settings" {
135-
return true, nil // Other permissions are sensitive.
130+
slices.Sort(keyRes.ACL)
131+
extraData := map[string]string{
132+
"acl": strings.Join(keyRes.ACL, ","),
136133
}
134+
if keyRes.Description != "" && keyRes.Description != "<redacted>" {
135+
extraData["description"] = keyRes.Description
136+
}
137+
return true, extraData, nil
138+
case http.StatusUnauthorized:
139+
return false, nil, nil
140+
case http.StatusForbidden:
141+
// Key is valid but lacks permissions.
142+
return true, nil, nil
143+
default:
144+
return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
137145
}
146+
}
138147

139-
return false, nil
148+
// https://www.algolia.com/doc/rest-api/search/#tag/Api-Keys/operation/getApiKey
149+
type keyResponse struct {
150+
ACL []string `json:"acl"`
151+
Description string `json:"description"`
140152
}
141153

142154
func (s Scanner) Type() detectorspb.DetectorType {

0 commit comments

Comments
 (0)