Skip to content

Commit fb1d755

Browse files
committed
feat(detectors): add Gitea API token detector
Detect Gitea API tokens (40-char lowercase hex, anchored on the "gitea" keyword) and verify them against `GET /api/v1/user` using the `Authorization: token <token>` header. Supports self-hosted instances through the standard endpoint customization (defaults to gitea.com). Populates SecretParts on every result and distinguishes determinate from indeterminate verification failures. Registered in the default detector set. Includes pattern tests, verification tests covering all five detector states, and a build-tagged integration test. Closes #4718
1 parent 4ece10b commit fb1d755

4 files changed

Lines changed: 429 additions & 0 deletions

File tree

pkg/detectors/gitea/gitea.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package gitea
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"strings"
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+
// Ensure the Scanner satisfies the interfaces at compile time.
23+
var (
24+
_ detectors.Detector = (*Scanner)(nil)
25+
_ detectors.EndpointCustomizer = (*Scanner)(nil)
26+
_ detectors.CloudProvider = (*Scanner)(nil)
27+
)
28+
29+
func (Scanner) CloudEndpoint() string { return "https://gitea.com" }
30+
31+
var (
32+
defaultClient = common.SaneHttpClient()
33+
34+
// Gitea API tokens are 40-character lowercase hexadecimal strings.
35+
keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"gitea"}) + `\b([a-f0-9]{40})\b`)
36+
)
37+
38+
func (s Scanner) getClient() *http.Client {
39+
if s.client != nil {
40+
return s.client
41+
}
42+
43+
return defaultClient
44+
}
45+
46+
// Keywords are used for efficiently pre-filtering chunks.
47+
// Use identifiers in the secret preferably, or the provider name.
48+
func (s Scanner) Keywords() []string {
49+
return []string{"gitea"}
50+
}
51+
52+
func (s Scanner) Type() detector_typepb.DetectorType {
53+
return detector_typepb.DetectorType_Gitea
54+
}
55+
56+
func (s Scanner) Description() string {
57+
return "Gitea is a self-hosted, lightweight Git service. Gitea API tokens can be used to access and modify repositories, organizations, issues, and other resources."
58+
}
59+
60+
// FromData will find and optionally verify Gitea secrets in a given set of bytes.
61+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
62+
dataStr := string(data)
63+
64+
uniqueMatches := make(map[string]struct{})
65+
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
66+
uniqueMatches[strings.TrimSpace(match[1])] = struct{}{}
67+
}
68+
69+
for token := range uniqueMatches {
70+
for _, endpoint := range s.Endpoints() {
71+
s1 := detectors.Result{
72+
DetectorType: detector_typepb.DetectorType_Gitea,
73+
Raw: []byte(token),
74+
RawV2: []byte(token + endpoint),
75+
SecretParts: map[string]string{
76+
"key": token,
77+
},
78+
ExtraData: map[string]string{
79+
"host": endpoint,
80+
},
81+
}
82+
83+
if verify {
84+
isVerified, verificationErr := verifyGitea(ctx, s.getClient(), endpoint, token)
85+
s1.Verified = isVerified
86+
s1.SetVerificationError(verificationErr, token)
87+
88+
// For verified keys break out of the endpoint loop to continue to the next secret.
89+
if s1.Verified {
90+
results = append(results, s1)
91+
break
92+
}
93+
}
94+
95+
results = append(results, s1)
96+
}
97+
}
98+
99+
return results, nil
100+
}
101+
102+
func verifyGitea(ctx context.Context, client *http.Client, baseEndpoint, token string) (bool, error) {
103+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseEndpoint+"/api/v1/user", http.NoBody)
104+
if err != nil {
105+
return false, err
106+
}
107+
108+
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
109+
res, err := client.Do(req)
110+
if err != nil {
111+
return false, err
112+
}
113+
defer func() {
114+
_, _ = io.Copy(io.Discard, res.Body)
115+
_ = res.Body.Close()
116+
}()
117+
118+
switch res.StatusCode {
119+
case http.StatusOK:
120+
return true, nil
121+
case http.StatusUnauthorized, http.StatusForbidden:
122+
// The token is determinately invalid (or revoked / lacking access).
123+
return false, nil
124+
default:
125+
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
126+
}
127+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package gitea
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 TestGitea_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+
secret := testSecrets.MustGetField("GITEA")
28+
inactiveSecret := testSecrets.MustGetField("GITEA_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: Scanner{},
46+
args: args{
47+
ctx: ctx,
48+
data: []byte(fmt.Sprintf("gitea token = %s", secret)),
49+
verify: true,
50+
},
51+
want: []detectors.Result{
52+
{
53+
DetectorType: detector_typepb.DetectorType_Gitea,
54+
Verified: true,
55+
},
56+
},
57+
wantErr: false,
58+
},
59+
{
60+
name: "found, unverified",
61+
s: Scanner{},
62+
args: args{
63+
ctx: ctx,
64+
data: []byte(fmt.Sprintf("gitea token = %s", inactiveSecret)),
65+
verify: true,
66+
},
67+
want: []detectors.Result{
68+
{
69+
DetectorType: detector_typepb.DetectorType_Gitea,
70+
Verified: false,
71+
},
72+
},
73+
wantErr: false,
74+
},
75+
{
76+
name: "not found",
77+
s: Scanner{},
78+
args: args{
79+
ctx: ctx,
80+
data: []byte("You cannot find the secret within"),
81+
verify: true,
82+
},
83+
want: nil,
84+
wantErr: false,
85+
},
86+
}
87+
for _, tt := range tests {
88+
t.Run(tt.name, func(t *testing.T) {
89+
s := tt.s
90+
s.SetCloudEndpoint(s.CloudEndpoint())
91+
s.UseCloudEndpoint(true)
92+
got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
93+
if (err != nil) != tt.wantErr {
94+
t.Errorf("Gitea.FromData() error = %v, wantErr %v", err, tt.wantErr)
95+
return
96+
}
97+
for i := range got {
98+
if len(got[i].Raw) == 0 {
99+
t.Fatalf("no raw secret present: \n %+v", got[i])
100+
}
101+
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
102+
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
103+
}
104+
}
105+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "ExtraData", "verificationError")
106+
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
107+
t.Errorf("Gitea.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
108+
}
109+
})
110+
}
111+
}

0 commit comments

Comments
 (0)