Skip to content

Commit 94b4f52

Browse files
authored
[Feat] Detector implementation for Azure API Management Repository key (#3955)
* detector for azure repository initial commit * included pattern test and integration test for azure api management repository key detector * remove unused http.Client field. * generate proto
1 parent b2ba219 commit 94b4f52

File tree

6 files changed

+415
-6
lines changed

6 files changed

+415
-6
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package repositorykey
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/url"
8+
"os/exec"
9+
"strconv"
10+
"strings"
11+
12+
regexp "github.com/wasilibs/go-re2"
13+
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/cache/simple"
15+
logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context"
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
17+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
18+
)
19+
20+
type Scanner struct {
21+
detectors.DefaultMultiPartCredentialProvider
22+
}
23+
24+
// Ensure the Scanner satisfies the interface at compile time.
25+
var _ detectors.Detector = (*Scanner)(nil)
26+
27+
var (
28+
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
29+
urlPat = regexp.MustCompile(detectors.PrefixRegex([]string{"azure", "url"}) + `([a-z0-9][a-z0-9-]{0,48}[a-z0-9]\.scm\.azure-api\.net)`)
30+
passwordPat = regexp.MustCompile(detectors.PrefixRegex([]string{"azure", "password"}) + `\b(git&[0-9]{12}&[a-zA-Z0-9\/+]{85}[a-zA-Z0-9]==)`)
31+
32+
invalidHosts = simple.NewCache[struct{}]()
33+
noSuchHostErr = errors.New("Could not resolve host")
34+
)
35+
36+
const (
37+
azureGitUsername = "apim"
38+
)
39+
40+
// Keywords are used for efficiently pre-filtering chunks.
41+
// Use identifiers in the secret preferably, or the provider name.
42+
func (s Scanner) Keywords() []string {
43+
return []string{"azure", ".scm.azure-api.net"}
44+
}
45+
46+
// FromData will find and optionally verify AzureDevopsPersonalAccessToken secrets in a given set of bytes.
47+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
48+
logger := logContext.AddLogger(ctx).Logger().WithName("azurecr")
49+
dataStr := string(data)
50+
51+
// Deduplicate matches.
52+
uniqueUrlsMatches := make(map[string]struct{})
53+
uniquePasswordMatches := make(map[string]struct{})
54+
55+
for _, matches := range urlPat.FindAllStringSubmatch(dataStr, -1) {
56+
uniqueUrlsMatches[strings.TrimSpace(matches[1])] = struct{}{}
57+
}
58+
59+
for _, matches := range passwordPat.FindAllStringSubmatch(dataStr, -1) {
60+
uniquePasswordMatches[strings.TrimSpace(matches[1])] = struct{}{}
61+
}
62+
63+
EndpointLoop:
64+
for urlMatch := range uniqueUrlsMatches {
65+
for passwordMatch := range uniquePasswordMatches {
66+
s1 := detectors.Result{
67+
DetectorType: detectorspb.DetectorType_AzureApiManagementRepositoryKey,
68+
Raw: []byte(passwordMatch),
69+
RawV2: []byte(urlMatch + passwordMatch),
70+
}
71+
72+
if verify {
73+
if invalidHosts.Exists(urlMatch) {
74+
logger.V(3).Info("Skipping invalid registry", "url", urlMatch)
75+
continue EndpointLoop
76+
}
77+
78+
isVerified, err := verifyUrlPassword(ctx, urlMatch, azureGitUsername, passwordMatch)
79+
s1.Verified = isVerified
80+
if err != nil {
81+
if errors.Is(err, noSuchHostErr) {
82+
invalidHosts.Set(urlMatch, struct{}{})
83+
continue EndpointLoop
84+
}
85+
s1.SetVerificationError(err, urlMatch)
86+
}
87+
}
88+
results = append(results, s1)
89+
}
90+
}
91+
92+
return results, nil
93+
}
94+
95+
func (s Scanner) Type() detectorspb.DetectorType {
96+
return detectorspb.DetectorType_AzureApiManagementRepositoryKey
97+
}
98+
99+
func (s Scanner) Description() string {
100+
return "Azure API Management Repository Keys provide access to the API Management (APIM) configuration repository, allowing users to directly interact with and modify API definitions, policies, and settings. These keys enable programmatic access to APIM's Git-based repository, where configurations can be cloned, edited, and pushed back to apply changes. They are primarily used for managing API configurations as code, automating deployments, and synchronizing APIM settings across environments."
101+
}
102+
103+
func gitCmdCheck() error {
104+
if errors.Is(exec.Command("git").Run(), exec.ErrNotFound) {
105+
return fmt.Errorf("'git' command not found in $PATH. Make sure git is installed and included in $PATH")
106+
}
107+
108+
// Check the version is greater than or equal to 2.20.0
109+
out, err := exec.Command("git", "--version").Output()
110+
if err != nil {
111+
return fmt.Errorf("failed to check git version: %w", err)
112+
}
113+
114+
// Extract the version string using a regex to find the version numbers
115+
var regex = regexp.MustCompile(`\d+\.\d+\.\d+`)
116+
117+
versionStr := regex.FindString(string(out))
118+
versionParts := strings.Split(versionStr, ".")
119+
120+
// Parse version numbers
121+
major, _ := strconv.Atoi(versionParts[0])
122+
minor, _ := strconv.Atoi(versionParts[1])
123+
124+
// Compare with version 2.20.0<=x<3.0.0
125+
if major == 2 && minor >= 20 {
126+
return nil
127+
}
128+
return fmt.Errorf("git version is %s, but must be greater than or equal to 2.20.0, and less than 3.0.0", versionStr)
129+
}
130+
131+
func verifyUrlPassword(_ context.Context, repoUrl, user, password string) (bool, error) {
132+
if err := gitCmdCheck(); err != nil {
133+
return false, err
134+
}
135+
136+
parsedURL, err := url.Parse(repoUrl)
137+
if err != nil {
138+
return false, err
139+
}
140+
141+
if parsedURL.User == nil {
142+
parsedURL.User = url.UserPassword(user, password)
143+
}
144+
parsedURL.Scheme = "https" // Force HTTPS
145+
146+
fakeRef := "TRUFFLEHOG_CHECK_GIT_REMOTE_URL_REACHABILITY"
147+
gitArgs := []string{"ls-remote", parsedURL.String(), "--quiet", fakeRef}
148+
cmd := exec.Command("git", gitArgs...)
149+
output, err := cmd.CombinedOutput()
150+
if err != nil {
151+
outputString := string(output)
152+
if strings.Contains(outputString, "Authentication failed") {
153+
return false, nil
154+
} else if strings.Contains(outputString, "Could not resolve host") {
155+
return false, noSuchHostErr
156+
}
157+
return false, err
158+
}
159+
160+
return true, nil
161+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package repositorykey
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+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
16+
17+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
18+
)
19+
20+
func TestAxonaut_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+
url := testSecrets.MustGetField("AZURE_API_MGMT_REPOSITORY_KEY_URL")
28+
inactiveUrl := testSecrets.MustGetField("AZURE_API_MGMT_REPOSITORY_KEY_URL_INACTIVE")
29+
password := testSecrets.MustGetField("AZURE_API_MGMT_REPOSITORY_KEY_PASSWORD")
30+
inactivePassword := testSecrets.MustGetField("AZURE_API_MGMT_REPOSITORY_KEY_PASSWORD_INACTIVE")
31+
32+
type args struct {
33+
ctx context.Context
34+
data []byte
35+
verify bool
36+
}
37+
tests := []struct {
38+
name string
39+
s Scanner
40+
args args
41+
want []detectors.Result
42+
wantErr bool
43+
}{
44+
{
45+
name: "found, verified",
46+
s: Scanner{},
47+
args: args{
48+
ctx: context.Background(),
49+
data: []byte(fmt.Sprintf("You can find a azure repository url %s password %s", url, password)),
50+
verify: true,
51+
},
52+
want: []detectors.Result{
53+
{
54+
DetectorType: detectorspb.DetectorType_AzureApiManagementRepositoryKey,
55+
Verified: true,
56+
},
57+
},
58+
wantErr: false,
59+
},
60+
{
61+
name: "found, unverified",
62+
s: Scanner{},
63+
args: args{
64+
ctx: context.Background(),
65+
data: []byte(fmt.Sprintf("You can find a azure repository url %s password %s but unverified", url, inactivePassword)),
66+
verify: true,
67+
},
68+
want: []detectors.Result{
69+
{
70+
DetectorType: detectorspb.DetectorType_AzureApiManagementRepositoryKey,
71+
Verified: false,
72+
},
73+
},
74+
wantErr: false,
75+
},
76+
{
77+
name: "not found",
78+
s: Scanner{},
79+
args: args{
80+
ctx: context.Background(),
81+
data: []byte("You cannot find the secret within"),
82+
verify: true,
83+
},
84+
want: nil,
85+
wantErr: false,
86+
},
87+
{
88+
name: "found, host not resolved",
89+
s: Scanner{},
90+
args: args{
91+
ctx: context.Background(),
92+
data: []byte(fmt.Sprintf("You can find a azure repository url %s password %s but unverified", inactiveUrl, inactivePassword)),
93+
verify: true,
94+
},
95+
want: nil,
96+
wantErr: false,
97+
},
98+
}
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
s := Scanner{}
102+
got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
103+
if (err != nil) != tt.wantErr {
104+
t.Errorf("AzureApiManagementRepositoryKey.FromData() error = %v, wantErr %v", err, tt.wantErr)
105+
return
106+
}
107+
for i := range got {
108+
if len(got[i].Raw) == 0 {
109+
t.Fatalf("no raw secret present: \n %+v", got[i])
110+
}
111+
}
112+
113+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "ExtraData", "AnalysisInfo")
114+
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
115+
t.Errorf("AzureApiManagementRepositoryKey.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
116+
}
117+
})
118+
}
119+
}
120+
121+
func BenchmarkFromData(benchmark *testing.B) {
122+
ctx := context.Background()
123+
s := Scanner{}
124+
for name, data := range detectors.MustGetBenchmarkData() {
125+
benchmark.Run(name, func(b *testing.B) {
126+
b.ResetTimer()
127+
for n := 0; n < b.N; n++ {
128+
_, err := s.FromData(ctx, false, data)
129+
if err != nil {
130+
b.Fatal(err)
131+
}
132+
}
133+
})
134+
}
135+
}

0 commit comments

Comments
 (0)