Skip to content

Commit 091fed4

Browse files
committed
feat(detectors): add Tencent Cloud credential detector
Detects Tencent Cloud SecretId (AKID-prefixed) paired with a SecretKey and verifies the pair against the CVM DescribeRegions API using a TC3-HMAC-SHA256 signed request. Tencent returns HTTP 200 with an Error block in the body for authentication failures, so verification inspects the response body and treats AuthFailure.* codes as determinately invalid. Refs #4036
1 parent 503c905 commit 091fed4

4 files changed

Lines changed: 516 additions & 0 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package tencentcloud
2+
3+
import (
4+
"context"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/hex"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"strconv"
13+
"strings"
14+
"time"
15+
16+
regexp "github.com/wasilibs/go-re2"
17+
18+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
19+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
20+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
21+
)
22+
23+
type Scanner struct {
24+
client *http.Client
25+
detectors.DefaultMultiPartCredentialProvider
26+
}
27+
28+
// Ensure the Scanner satisfies the interface at compile time.
29+
var _ detectors.Detector = (*Scanner)(nil)
30+
31+
var (
32+
defaultClient = common.SaneHttpClient()
33+
34+
// SecretId always starts with the "AKID" prefix followed by 32 alphanumeric characters.
35+
idPat = regexp.MustCompile(`\b(AKID[A-Za-z0-9]{32})\b`)
36+
// SecretKey is a generic 32-character alphanumeric string; it is paired with a SecretId.
37+
secretPat = regexp.MustCompile(`\b([A-Za-z0-9]{32})\b`)
38+
)
39+
40+
func (s Scanner) getClient() *http.Client {
41+
if s.client != nil {
42+
return s.client
43+
}
44+
45+
return defaultClient
46+
}
47+
48+
// Keywords are used for efficiently pre-filtering chunks.
49+
func (s Scanner) Keywords() []string {
50+
return []string{"AKID"}
51+
}
52+
53+
func (s Scanner) Type() detector_typepb.DetectorType {
54+
return detector_typepb.DetectorType_TencentCloud
55+
}
56+
57+
func (s Scanner) Description() string {
58+
return "Tencent Cloud is a cloud computing platform offering compute, storage, database and 200+ other services. SecretId/SecretKey pairs grant programmatic access to the account's cloud resources."
59+
}
60+
61+
// FromData will find and optionally verify Tencent Cloud secrets in a given set of bytes.
62+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
63+
dataStr := string(data)
64+
65+
idMatches := make(map[string]struct{})
66+
for _, match := range idPat.FindAllStringSubmatch(dataStr, -1) {
67+
idMatches[match[1]] = struct{}{}
68+
}
69+
70+
secretMatches := make(map[string]struct{})
71+
for _, match := range secretPat.FindAllStringSubmatch(dataStr, -1) {
72+
secretMatches[match[1]] = struct{}{}
73+
}
74+
75+
for id := range idMatches {
76+
for secret := range secretMatches {
77+
// Skip low-entropy secrets to reduce false positives from generic 32-char strings.
78+
if detectors.StringShannonEntropy(secret) < 3.0 {
79+
continue
80+
}
81+
82+
s1 := detectors.Result{
83+
DetectorType: detector_typepb.DetectorType_TencentCloud,
84+
Raw: []byte(id),
85+
RawV2: []byte(id + ":" + secret),
86+
Redacted: id,
87+
SecretParts: map[string]string{
88+
"secret_id": id,
89+
"secret_key": secret,
90+
},
91+
}
92+
93+
if verify {
94+
isVerified, verificationErr := verifyMatch(ctx, s.getClient(), id, secret)
95+
s1.Verified = isVerified
96+
s1.SetVerificationError(verificationErr, secret)
97+
}
98+
99+
results = append(results, s1)
100+
101+
// Once a SecretId is verified with a SecretKey, stop pairing it with other secrets.
102+
if s1.Verified {
103+
break
104+
}
105+
}
106+
}
107+
108+
return results, nil
109+
}
110+
111+
const (
112+
verifyHost = "cvm.tencentcloudapi.com"
113+
verifyService = "cvm"
114+
verifyAction = "DescribeRegions"
115+
verifyVersion = "2017-03-12"
116+
verifyRegion = "ap-guangzhou"
117+
)
118+
119+
// tencentResponse models the envelope every Tencent Cloud 3.0 API returns.
120+
// Authentication failures are reported with HTTP 200 and an Error block in the body.
121+
type tencentResponse struct {
122+
Response struct {
123+
Error *struct {
124+
Code string `json:"Code"`
125+
Message string `json:"Message"`
126+
} `json:"Error"`
127+
} `json:"Response"`
128+
}
129+
130+
func verifyMatch(ctx context.Context, client *http.Client, secretID, secretKey string) (bool, error) {
131+
const payload = "{}"
132+
now := time.Now().UTC()
133+
timestamp := strconv.FormatInt(now.Unix(), 10)
134+
date := now.Format("2006-01-02")
135+
136+
// Build the TC3-HMAC-SHA256 authorization header.
137+
// https://www.tencentcloud.com/document/api/213/31574
138+
contentType := "application/json; charset=utf-8"
139+
canonicalHeaders := "content-type:" + contentType + "\nhost:" + verifyHost + "\n"
140+
signedHeaders := "content-type;host"
141+
hashedPayload := sha256Hex(payload)
142+
canonicalRequest := strings.Join([]string{
143+
http.MethodPost,
144+
"/",
145+
"",
146+
canonicalHeaders,
147+
signedHeaders,
148+
hashedPayload,
149+
}, "\n")
150+
151+
credentialScope := date + "/" + verifyService + "/tc3_request"
152+
stringToSign := strings.Join([]string{
153+
"TC3-HMAC-SHA256",
154+
timestamp,
155+
credentialScope,
156+
sha256Hex(canonicalRequest),
157+
}, "\n")
158+
159+
secretDate := hmacSHA256([]byte("TC3"+secretKey), date)
160+
secretService := hmacSHA256(secretDate, verifyService)
161+
secretSigning := hmacSHA256(secretService, "tc3_request")
162+
signature := hex.EncodeToString(hmacSHA256(secretSigning, stringToSign))
163+
164+
authorization := fmt.Sprintf(
165+
"TC3-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
166+
secretID, credentialScope, signedHeaders, signature,
167+
)
168+
169+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+verifyHost+"/", strings.NewReader(payload))
170+
if err != nil {
171+
return false, err
172+
}
173+
req.Header.Set("Content-Type", contentType)
174+
req.Header.Set("Host", verifyHost)
175+
req.Header.Set("Authorization", authorization)
176+
req.Header.Set("X-TC-Action", verifyAction)
177+
req.Header.Set("X-TC-Version", verifyVersion)
178+
req.Header.Set("X-TC-Timestamp", timestamp)
179+
req.Header.Set("X-TC-Region", verifyRegion)
180+
181+
res, err := client.Do(req)
182+
if err != nil {
183+
return false, err
184+
}
185+
defer func() {
186+
_, _ = io.Copy(io.Discard, res.Body)
187+
_ = res.Body.Close()
188+
}()
189+
190+
if res.StatusCode != http.StatusOK {
191+
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
192+
}
193+
194+
body, err := io.ReadAll(res.Body)
195+
if err != nil {
196+
return false, err
197+
}
198+
199+
var parsed tencentResponse
200+
if err := json.Unmarshal(body, &parsed); err != nil {
201+
return false, err
202+
}
203+
204+
// No error block means the signature was accepted and the credentials are valid.
205+
if parsed.Response.Error == nil {
206+
return true, nil
207+
}
208+
// Authentication errors are determinate: the credentials are invalid.
209+
if strings.HasPrefix(parsed.Response.Error.Code, "AuthFailure") {
210+
return false, nil
211+
}
212+
// Any other error code is unexpected; surface it as an indeterminate result.
213+
return false, fmt.Errorf("unexpected error code %q: %s", parsed.Response.Error.Code, parsed.Response.Error.Message)
214+
}
215+
216+
func sha256Hex(s string) string {
217+
h := sha256.Sum256([]byte(s))
218+
return hex.EncodeToString(h[:])
219+
}
220+
221+
func hmacSHA256(key []byte, msg string) []byte {
222+
h := hmac.New(sha256.New, key)
223+
h.Write([]byte(msg))
224+
return h.Sum(nil)
225+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package tencentcloud
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 TestTencentCloud_FromChunk(t *testing.T) {
21+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
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+
secretID := testSecrets.MustGetField("TENCENTCLOUD_SECRET_ID")
28+
secretKey := testSecrets.MustGetField("TENCENTCLOUD_SECRET_KEY")
29+
inactiveSecretKey := testSecrets.MustGetField("TENCENTCLOUD_SECRET_KEY_INACTIVE")
30+
31+
type args struct {
32+
ctx context.Context
33+
data []byte
34+
verify bool
35+
}
36+
tests := []struct {
37+
name string
38+
s Scanner
39+
args args
40+
want []detectors.Result
41+
wantErr bool
42+
wantVerificationErr bool
43+
}{
44+
{
45+
name: "found, verified",
46+
s: Scanner{},
47+
args: args{
48+
ctx: ctx,
49+
data: []byte(fmt.Sprintf("tencentcloud secret_id = %s secret_key = %s", secretID, secretKey)),
50+
verify: true,
51+
},
52+
want: []detectors.Result{
53+
{
54+
DetectorType: detector_typepb.DetectorType_TencentCloud,
55+
Verified: true,
56+
},
57+
},
58+
wantErr: false,
59+
},
60+
{
61+
name: "found, unverified",
62+
s: Scanner{},
63+
args: args{
64+
ctx: ctx,
65+
data: []byte(fmt.Sprintf("tencentcloud secret_id = %s secret_key = %s", secretID, inactiveSecretKey)),
66+
verify: true,
67+
},
68+
want: []detectors.Result{
69+
{
70+
DetectorType: detector_typepb.DetectorType_TencentCloud,
71+
Verified: false,
72+
},
73+
},
74+
wantErr: false,
75+
},
76+
{
77+
name: "not found",
78+
s: Scanner{},
79+
args: args{
80+
ctx: ctx,
81+
data: []byte("You cannot find the secret within"),
82+
verify: true,
83+
},
84+
want: nil,
85+
wantErr: false,
86+
},
87+
}
88+
for _, tt := range tests {
89+
t.Run(tt.name, func(t *testing.T) {
90+
s := tt.s
91+
got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
92+
if (err != nil) != tt.wantErr {
93+
t.Errorf("TencentCloud.FromData() error = %v, wantErr %v", err, tt.wantErr)
94+
return
95+
}
96+
for i := range got {
97+
if len(got[i].Raw) == 0 {
98+
t.Fatalf("no raw secret present: \n %+v", got[i])
99+
}
100+
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
101+
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
102+
}
103+
}
104+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "Redacted", "ExtraData", "SecretParts", "verificationError")
105+
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
106+
t.Errorf("TencentCloud.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
107+
}
108+
})
109+
}
110+
}

0 commit comments

Comments
 (0)