Skip to content

Commit e828423

Browse files
Add support for detecting and validating CastAI API tokens
Fixes #4925
1 parent 3fc0c2a commit e828423

7 files changed

Lines changed: 432 additions & 9 deletions

File tree

pkg/detectors/aws/access_keys/accesskey.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package access_keys
22

33
import (
4+
"maps"
45
"context"
56
"fmt"
67
"net"
@@ -220,9 +221,7 @@ func (s scanner) FromData(ctx context.Context, verify bool, data []byte) (result
220221
}
221222

222223
// Append the extraData to the existing ExtraData map.
223-
for k, v := range extraData {
224-
s1.ExtraData[k] = v
225-
}
224+
maps.Copy(s1.ExtraData, extraData)
226225
s1.SetVerificationError(verificationErr, secretMatch)
227226
}
228227
}

pkg/detectors/castai/castai.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package castai
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"maps"
8+
"net/http"
9+
10+
regexp "github.com/wasilibs/go-re2"
11+
12+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
15+
)
16+
17+
type scanner struct {
18+
client *http.Client
19+
detectors.EndpointSetter
20+
}
21+
22+
func New(opts ...func(*scanner)) *scanner {
23+
scanner := &scanner{}
24+
25+
// Default endpoints.
26+
_ = scanner.SetConfiguredEndpoints(
27+
"https://api.cast.ai/v1/kubernetes/external-clusters",
28+
"https://api.eu.cast.ai/v1/kubernetes/external-clusters",
29+
)
30+
31+
for _, opt := range opts {
32+
opt(scanner)
33+
}
34+
35+
return scanner
36+
}
37+
38+
func WithClient(c *http.Client) func(*scanner) {
39+
return func(s *scanner) {
40+
s.client = c
41+
}
42+
}
43+
44+
// Ensure the Scanner satisfies the interface at compile time.
45+
var _ detectors.Detector = (*scanner)(nil)
46+
var _ detectors.EndpointCustomizer = (*scanner)(nil)
47+
var _ detectors.Versioner = (*scanner)(nil)
48+
49+
var (
50+
defaultClient = common.SaneHttpClient()
51+
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
52+
keyPat = regexp.MustCompile(`\b(castai_v1_[a-z0-9]{64}_[a-z0-9]{8})\b`)
53+
)
54+
55+
// Keywords are used for efficiently pre-filtering chunks.
56+
// Use identifiers in the secret preferably, or the provider name.
57+
func (s scanner) Keywords() []string {
58+
return []string{"castai_v1_"} // Prefix
59+
}
60+
61+
func (scanner) Version() int {
62+
return 1
63+
}
64+
65+
// FromData will find and optionally verify Castai secrets in a given set of bytes.
66+
func (s scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
67+
dataStr := string(data)
68+
69+
uniqueMatches := make(map[string]struct{})
70+
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
71+
uniqueMatches[match[1]] = struct{}{}
72+
}
73+
74+
for match := range uniqueMatches {
75+
s1 := detectors.Result{
76+
DetectorType: detector_typepb.DetectorType_CastAI,
77+
Raw: []byte(match),
78+
SecretParts: map[string]string{"key": match},
79+
}
80+
81+
if verify {
82+
client := s.client
83+
if client == nil {
84+
client = defaultClient
85+
}
86+
87+
for _, endpoint := range s.Endpoints() {
88+
isVerified, extraData, verificationErr := verifyMatch(ctx, client, endpoint, match)
89+
// A token can only be valid in a single environment.
90+
if !isVerified && verificationErr == nil {
91+
continue
92+
}
93+
94+
s1.Verified = isVerified
95+
s1.ExtraData = map[string]string{
96+
"endpoint": endpoint,
97+
}
98+
maps.Copy(s1.ExtraData, extraData)
99+
s1.SetVerificationError(verificationErr, match)
100+
break
101+
}
102+
}
103+
104+
results = append(results, s1)
105+
}
106+
107+
return
108+
}
109+
110+
func verifyMatch(ctx context.Context, client *http.Client, endpoint string, token string) (bool, map[string]string, error) {
111+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
112+
if err != nil {
113+
return false, nil, err
114+
}
115+
116+
req.Header.Set("X-API-Key", token)
117+
118+
res, err := client.Do(req)
119+
if err != nil {
120+
return false, nil, err
121+
}
122+
defer func() {
123+
_, _ = io.Copy(io.Discard, res.Body)
124+
_ = res.Body.Close()
125+
}()
126+
127+
switch res.StatusCode {
128+
case http.StatusOK:
129+
// If the endpoint returns useful information, we can return it as a map.
130+
return true, nil, nil
131+
case http.StatusUnauthorized:
132+
// The secret is determinately not verified (nothing to do)
133+
return false, nil, nil
134+
default:
135+
return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
136+
}
137+
}
138+
139+
func (s scanner) Type() detector_typepb.DetectorType {
140+
return detector_typepb.DetectorType_CastAI
141+
}
142+
143+
func (s scanner) Description() string {
144+
return "Castai is a blockchain development platform that provides a suite of tools and services for building and scaling decentralized applications. Castai API keys can be used to access these services."
145+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package castai
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"testing"
10+
"time"
11+
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/google/go-cmp/cmp/cmpopts"
14+
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
17+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
18+
)
19+
20+
func TestCastai_FromChunk(t *testing.T) {
21+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
22+
defer cancel()
23+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
24+
if err != nil {
25+
t.Fatalf("could not get test secrets from GCP: %s", err)
26+
}
27+
secret := testSecrets.MustGetField("CASTAI")
28+
inactiveSecret := testSecrets.MustGetField("CASTAI_INACTIVE")
29+
30+
type args struct {
31+
ctx context.Context
32+
data []byte
33+
verify bool
34+
}
35+
tests := []struct {
36+
name string
37+
s *scanner
38+
args args
39+
want []detectors.Result
40+
wantErr bool
41+
wantVerificationErr bool
42+
}{
43+
{
44+
name: "found, verified",
45+
s: New(),
46+
args: args{
47+
ctx: context.Background(),
48+
data: []byte(fmt.Sprintf("You can find a castai secret %s within", secret)),
49+
verify: true,
50+
},
51+
want: []detectors.Result{
52+
{
53+
DetectorType: detector_typepb.DetectorType_CastAI,
54+
Verified: true,
55+
ExtraData: map[string]string{
56+
"endpoint": "https://api.cast.ai/v1/kubernetes/external-clusters",
57+
},
58+
},
59+
},
60+
wantErr: false,
61+
wantVerificationErr: false,
62+
},
63+
{
64+
name: "found, unverified",
65+
s: New(),
66+
args: args{
67+
ctx: context.Background(),
68+
data: []byte(fmt.Sprintf("You can find a castai secret %s within but not valid", inactiveSecret)), // the secret would satisfy the regex but not pass validation
69+
verify: true,
70+
},
71+
want: []detectors.Result{
72+
{
73+
DetectorType: detector_typepb.DetectorType_CastAI,
74+
Verified: false,
75+
},
76+
},
77+
wantErr: false,
78+
wantVerificationErr: false,
79+
},
80+
{
81+
name: "not found",
82+
s: New(),
83+
args: args{
84+
ctx: context.Background(),
85+
data: []byte("You cannot find the secret within"),
86+
verify: true,
87+
},
88+
want: nil,
89+
wantErr: false,
90+
wantVerificationErr: false,
91+
},
92+
{
93+
name: "found, would be verified if not for timeout",
94+
s: New(WithClient(common.SaneHttpClientTimeOut(1 * time.Microsecond))),
95+
args: args{
96+
ctx: context.Background(),
97+
data: []byte(fmt.Sprintf("You can find a castai secret %s within", secret)),
98+
verify: true,
99+
},
100+
want: []detectors.Result{
101+
{
102+
DetectorType: detector_typepb.DetectorType_CastAI,
103+
Verified: false,
104+
ExtraData: map[string]string{
105+
"endpoint": "https://api.cast.ai/v1/kubernetes/external-clusters",
106+
},
107+
},
108+
},
109+
wantErr: false,
110+
wantVerificationErr: true,
111+
},
112+
{
113+
name: "found, verified but unexpected api surface",
114+
s: New(WithClient(common.ConstantResponseHttpClient(404, ""))),
115+
args: args{
116+
ctx: context.Background(),
117+
data: []byte(fmt.Sprintf("You can find a castai secret %s within", secret)),
118+
verify: true,
119+
},
120+
want: []detectors.Result{
121+
{
122+
DetectorType: detector_typepb.DetectorType_CastAI,
123+
Verified: false,
124+
ExtraData: map[string]string{
125+
"endpoint": "https://api.cast.ai/v1/kubernetes/external-clusters",
126+
},
127+
},
128+
},
129+
wantErr: false,
130+
wantVerificationErr: true,
131+
},
132+
}
133+
for _, tt := range tests {
134+
t.Run(tt.name, func(t *testing.T) {
135+
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
136+
if (err != nil) != tt.wantErr {
137+
t.Errorf("Castai.FromData() error = %v, wantErr %v", err, tt.wantErr)
138+
return
139+
}
140+
for i := range got {
141+
if len(got[i].Raw) == 0 {
142+
t.Fatalf("no raw secret present: \n %+v", got[i])
143+
}
144+
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
145+
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
146+
}
147+
}
148+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "primarySecret", "SecretParts")
149+
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
150+
t.Errorf("Castai.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
151+
}
152+
})
153+
}
154+
}
155+
156+
func BenchmarkFromData(benchmark *testing.B) {
157+
ctx := context.Background()
158+
s := New()
159+
for name, data := range detectors.MustGetBenchmarkData() {
160+
benchmark.Run(name, func(b *testing.B) {
161+
b.ResetTimer()
162+
for n := 0; n < b.N; n++ {
163+
_, err := s.FromData(ctx, false, data)
164+
if err != nil {
165+
b.Fatal(err)
166+
}
167+
}
168+
})
169+
}
170+
}

0 commit comments

Comments
 (0)