diff --git a/pkg/detectors/artifactory/artifactory.go b/pkg/detectors/artifactory/accesstoken/artifactoryaccesstoken.go similarity index 99% rename from pkg/detectors/artifactory/artifactory.go rename to pkg/detectors/artifactory/accesstoken/artifactoryaccesstoken.go index 856d90fa4b55..907483b7e0a4 100644 --- a/pkg/detectors/artifactory/artifactory.go +++ b/pkg/detectors/artifactory/accesstoken/artifactoryaccesstoken.go @@ -37,6 +37,7 @@ var ( errNoHost = errors.New("no such host") ) + func (Scanner) CloudEndpoint() string { return "" } // Keywords are used for efficiently pre-filtering chunks. diff --git a/pkg/detectors/artifactory/artifactory_integration_test.go b/pkg/detectors/artifactory/accesstoken/artifactoryaccesstoken_integration_test.go similarity index 100% rename from pkg/detectors/artifactory/artifactory_integration_test.go rename to pkg/detectors/artifactory/accesstoken/artifactoryaccesstoken_integration_test.go diff --git a/pkg/detectors/artifactory/artifactory_test.go b/pkg/detectors/artifactory/accesstoken/artifactoryaccesstoken_test.go similarity index 100% rename from pkg/detectors/artifactory/artifactory_test.go rename to pkg/detectors/artifactory/accesstoken/artifactoryaccesstoken_test.go diff --git a/pkg/detectors/artifactory/basicauth/artifactorybasicauth.go b/pkg/detectors/artifactory/basicauth/artifactorybasicauth.go new file mode 100644 index 000000000000..210a6fe4ab70 --- /dev/null +++ b/pkg/detectors/artifactory/basicauth/artifactorybasicauth.go @@ -0,0 +1,211 @@ +package artifactory + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + regexp "github.com/wasilibs/go-re2" + + "github.com/trufflesecurity/trufflehog/v3/pkg/cache/simple" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" +) + +type Scanner struct { + client *http.Client + detectors.DefaultMultiPartCredentialProvider +} + +type basicArtifactoryCredential struct { + username string + password string + host string + raw string +} + +var ( + // Ensure the Scanner satisfies the interface at compile time. + _ detectors.Detector = (*Scanner)(nil) + _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil) + + defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses + + basicAuthURLPattern = regexp.MustCompile( + `(?P[^:@\s\/]+):(?P[^:@\s\/]+)@(?P[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]\.jfrog\.io)(?P/[^\s"'<>]*)?`, + ) + + invalidHosts = simple.NewCache[struct{}]() + + errNoHost = errors.New("no such host") +) + + +// Keywords are used for efficiently pre-filtering chunks. +// Use identifiers in the secret preferably, or the provider name. +func (s Scanner) Keywords() []string { + return []string{"artifactory", "jfrog.io"} +} + +func (s Scanner) getClient() *http.Client { + if s.client != nil { + return s.client + } + return defaultClient +} + +// FromData will find and optionally verify Artifactory secrets in a given set of bytes. +func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { + dataStr := string(data) + + // ---------------------------------------- + // Basic Auth URI detection & verification + // ---------------------------------------- + basicCreds := make(map[string]basicArtifactoryCredential) + + for _, match := range basicAuthURLPattern.FindAllStringSubmatch(dataStr, -1) { + if len(match) == 0 { + continue + } + subexpNames := basicAuthURLPattern.SubexpNames() + + var username, password, host string + for i, name := range subexpNames { + if i == 0 || name == "" { + continue + } + switch name { + case "username": + username = match[i] + case "password": + password = match[i] + case "host": + host = match[i] + } + } + + if username == "" || password == "" || host == "" { + continue + } + + key := username + ":" + password + "@" + host + if _, exists := basicCreds[key]; exists { + continue + } + + basicCreds[key] = basicArtifactoryCredential{ + username: username, + password: password, + host: host, + raw: match[0], + } + } + + for _, cred := range basicCreds { + if invalidHosts.Exists(cred.host) { + continue + } + + r := detectors.Result{ + DetectorType: detectorspb.DetectorType_ArtifactoryBasicAuth, + Raw: []byte(cred.raw), + RawV2: []byte(cred.username + ":" + cred.password + "@" + cred.host), + } + + if verify { + isVerified, vErr := verifyArtifactoryBasicAuth(ctx, s.getClient(), cred.host, cred.username, cred.password) + r.Verified = isVerified + + if vErr != nil { + if errors.Is(vErr, errNoHost) { + invalidHosts.Set(cred.host, struct{}{}) + continue + } + r.SetVerificationError(vErr, cred.username, cred.password) + } + + if isVerified { + if r.AnalysisInfo == nil { + r.AnalysisInfo = make(map[string]string) + } + r.AnalysisInfo["domain"] = cred.host + r.AnalysisInfo["username"] = cred.username + r.AnalysisInfo["password"] = cred.password + r.AnalysisInfo["authType"] = "basic" + } + } + + results = append(results, r) + } + + return results, nil +} + +func (s Scanner) IsFalsePositive(_ detectors.Result) (bool, string) { + return false, "" +} + +func verifyArtifactoryBasicAuth(ctx context.Context, client *http.Client, host, username, password string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+"/artifactory/api/system/ping", nil) + if err != nil { + return false, err + } + + // Use HTTP Basic authentication with the parsed username and password. + req.SetBasicAuth(username, password) + + resp, err := client.Do(req) + if err != nil { + if strings.Contains(err.Error(), "no such host") { + return false, errNoHost + } + + return false, err + } + + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + switch resp.StatusCode { + case http.StatusOK: + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + + if strings.TrimSpace(string(body)) == "OK" { + return true, nil + } + + return false, nil + case http.StatusForbidden: + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + + // Ignore rate-limit / temporary block 403s + if strings.Contains(strings.ToLower(string(body)), "blocked due to recurrent request failures") { + return false, nil + } + + return true, nil + case http.StatusUnauthorized, http.StatusFound: + return false, nil + default: + return false, fmt.Errorf("unexpected HTTP response status %d", resp.StatusCode) + } +} + +func (s Scanner) Type() detectorspb.DetectorType { + return detectorspb.DetectorType_ArtifactoryBasicAuth +} + +func (s Scanner) Description() string { + return "Artifactory is a repository manager that supports all major package formats. Artifactory Basic Auth credentials can be used to authenticate and perform operations on repositories." +} diff --git a/pkg/detectors/artifactory/basicauth/artifactorybasicauth_integration_test.go b/pkg/detectors/artifactory/basicauth/artifactorybasicauth_integration_test.go new file mode 100644 index 000000000000..8c92066a5b95 --- /dev/null +++ b/pkg/detectors/artifactory/basicauth/artifactorybasicauth_integration_test.go @@ -0,0 +1,146 @@ +//go:build detectors +// +build detectors + +package artifactory + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" +) + +func TestArtifactory_FromChunk(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + basicAuthValid := testSecrets.MustGetField("ARTIFACTORY_BASIC_AUTH_VALID") + basicAuthInactive := testSecrets.MustGetField("ARTIFACTORY_BASIC_AUTH_INACTIVE") + + type args struct { + ctx context.Context + data []byte + verify bool + } + tests := []struct { + name string + s Scanner + args args + want []detectors.Result + wantErr bool + }{ + { + name: "found, verified", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf( + "You can find an Artifactory basic auth URL https://%s/artifactory/api/pypi/pypi/simple", + basicAuthValid, + )), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_ArtifactoryBasicAuth, + Verified: true, + }, + }, + wantErr: false, + }, + { + name: "found, unverified", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf( + "You can find an Artifactory basic auth URL https://%s/artifactory/api/pypi/pypi/simple but it's not valid", + basicAuthInactive, + )), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_ArtifactoryBasicAuth, + Verified: false, + }, + }, + wantErr: false, + }, + { + name: "not found", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte("You cannot find any Artifactory basic auth URL within this chunk"), + verify: true, + }, + want: nil, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("Artifactory.FromData() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if len(tt.want) == 0 { + if len(got) != 0 { + t.Fatalf("expected no results, got %d", len(got)) + } + return + } + + for i := range got { + if len(got[i].Raw) == 0 && len(got[i].RawV2) == 0 { + t.Fatalf("no raw secret present: \n %+v", got[i]) + } + gotErr := "" + if got[i].VerificationError() != nil { + gotErr = got[i].VerificationError().Error() + } + wantErr := "" + if tt.want[i].VerificationError() != nil { + wantErr = tt.want[i].VerificationError().Error() + } + if gotErr != wantErr { + t.Fatalf("wantVerificationError = %v, verification error = %v", + tt.want[i].VerificationError(), got[i].VerificationError()) + } + } + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "primarySecret") + if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { + t.Errorf("Artifactory.FromData() %s diff: (-got +want)\n%s", tt.name, diff) + } + }) + } +} + +func BenchmarkFromData(benchmark *testing.B) { + ctx := context.Background() + s := Scanner{} + for name, data := range detectors.MustGetBenchmarkData() { + benchmark.Run(name, func(b *testing.B) { + b.ResetTimer() + for n := 0; n < b.N; n++ { + _, err := s.FromData(ctx, false, data) + if err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/pkg/detectors/artifactory/basicauth/artifactorybasicauth_test.go b/pkg/detectors/artifactory/basicauth/artifactorybasicauth_test.go new file mode 100644 index 000000000000..3656e3424fd0 --- /dev/null +++ b/pkg/detectors/artifactory/basicauth/artifactorybasicauth_test.go @@ -0,0 +1,160 @@ +package artifactory + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" +) + +func TestArtifactory_Pattern(t *testing.T) { + d := Scanner{} + ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) + + tests := []struct { + name string + input string + want []string + }{ + { + name: "valid pattern - single valid basic auth uri", + input: ` + [INFO] Sending request to the artifactory API + [DEBUG] Some log line here + https://user123:ATBB123abcDEF456ghiJKL789mnoPQR@test.jfrog.io/artifactory/api/pypi/pypi/simple + [INFO] Response received: 200 OK + `, + want: []string{ + "user123:ATBB123abcDEF456ghiJKL789mnoPQR@test.jfrog.io", + }, + }, + { + name: "valid pattern - single valid basic auth uri with no http prefix", + input: ` + [INFO] artifactory request to https://test.jfrog.io/artifactory/api/ping + simpleuser123:ATBB123abcDEF456ghiJKL789mnoPQR@test.jfrog.io/artifactory/api/pypi/pypi/simple + `, + want: []string{ + "simpleuser123:ATBB123abcDEF456ghiJKL789mnoPQR@test.jfrog.io", + }, + }, + { + name: "valid pattern - single valid basic auth uri with postfix", + input: ` + https://user123:ATBB123abcDEF456ghiJKL789mnoPQR@test.jfrog.io?x=1 + [INFO] Response received: 200 OK + `, + want: []string{ + "user123:ATBB123abcDEF456ghiJKL789mnoPQR@test.jfrog.io", + }, + }, + { + name: "valid pattern - multiple basic auth uris with duplicates", + input: ` + [INFO] artifactory logs + https://user123:token123@test.jfrog.io/artifactory/api/foo + https://user123:token123@test.jfrog.io/artifactory/api/foo # duplicate + http://another:secret456@rwxtOp.jfrog.io/artifactory/api/bar + `, + want: []string{ + "user123:token123@test.jfrog.io", + "another:secret456@rwxtOp.jfrog.io", + }, + }, + { + name: "invalid pattern - invalid host (not jfrog.io)", + input: ` + [INFO] Sending request to the artifactory API + https://user123:token123@example.com/artifactory/api/ping + `, + want: nil, + }, + { + name: "invalid pattern - missing password", + input: ` + [INFO] Sending request to the artifactory API + http://user123:@test.jfrog.io/artifactory/api/ping + `, + want: nil, + }, + { + name: "invalid pattern - no basic auth uri present", + input: ` + [INFO] artifactory request to https://test.jfrog.io/artifactory/api/ping + [DEBUG] Using header Authorization: Bearer sometoken + `, + want: nil, + }, + { + name: "invalid pattern - one character subdomain", + input: ` + [INFO] artifactory logs + https://user123:token123@a.jfrog.io/artifactory/api/foo + `, + want: nil, + }, + { + name: "invalid pattern - domain starts with -", + input: ` + [INFO] artifactory logs + https://user123:token123@-test.jfrog.io/artifactory/api/foo + `, + want: nil, + }, + { + name: "invalid pattern - domain ends with -", + input: ` + [INFO] artifactory logs + https://user123:token123@test-.jfrog.io/artifactory/api/foo + `, + want: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Ensure the Aho-Corasick prefilter is triggered for positive cases. + matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) + if len(test.want) > 0 && len(matchedDetectors) == 0 { + t.Errorf("expected detector to be triggered for input containing Artifactory/jfrog keywords") + } + + results, err := d.FromData(context.Background(), false, []byte(test.input)) + require.NoError(t, err) + + if len(test.want) == 0 { + if len(results) != 0 { + t.Errorf("expected no results, got %d", len(results)) + } + return + } + + if len(results) != len(test.want) { + t.Errorf("mismatch in result count: expected %d, got %d", len(test.want), len(results)) + } + + actual := make(map[string]struct{}, len(results)) + for _, r := range results { + // Prefer RawV2 (sanitized username:password@host) if present. + val := string(r.RawV2) + if val == "" { + val = string(r.Raw) + } + actual[val] = struct{}{} + } + + expected := make(map[string]struct{}, len(test.want)) + for _, v := range test.want { + expected[v] = struct{}{} + } + + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("%s diff: (-want +got)\n%s", test.name, diff) + } + }) + } +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index efba1ce8295d..566535fe9ede 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -46,7 +46,8 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/appoptics" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/appsynergy" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/apptivo" - "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/artifactory" + artifactoryaccesstoken "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/artifactory/accesstoken" + artifactorybasicauth "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/artifactory/basicauth" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/artsy" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/asanaoauth" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/asanapersonalaccesstoken" @@ -909,7 +910,8 @@ func buildDetectorList() []detectors.Detector { &appoptics.Scanner{}, &appsynergy.Scanner{}, &apptivo.Scanner{}, - &artifactory.Scanner{}, + &artifactoryaccesstoken.Scanner{}, + &artifactorybasicauth.Scanner{}, &artsy.Scanner{}, &asanaoauth.Scanner{}, &asanapersonalaccesstoken.Scanner{}, diff --git a/pkg/pb/detectorspb/detectors.pb.go b/pkg/pb/detectorspb/detectors.pb.go index 0d9abb0bad2b..703ad9c19f46 100644 --- a/pkg/pb/detectorspb/detectors.pb.go +++ b/pkg/pb/detectorspb/detectors.pb.go @@ -1147,6 +1147,7 @@ const ( DetectorType_Photoroom DetectorType = 1038 DetectorType_JWT DetectorType = 1039 DetectorType_OpenAIAdmin DetectorType = 1040 + DetectorType_ArtifactoryBasicAuth DetectorType = 1041 ) // Enum value maps for DetectorType. @@ -2189,6 +2190,7 @@ var ( 1038: "Photoroom", 1039: "JWT", 1040: "OpenAIAdmin", + 1041: "ArtifactoryBasicAuth", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3228,6 +3230,7 @@ var ( "Photoroom": 1038, "JWT": 1039, "OpenAIAdmin": 1040, + "ArtifactoryBasicAuth": 1041, } ) @@ -3681,7 +3684,7 @@ var file_detectors_proto_rawDesc = []byte{ 0x4c, 0x41, 0x49, 0x4e, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x41, 0x53, 0x45, 0x36, 0x34, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x55, 0x54, 0x46, 0x31, 0x36, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x45, 0x53, 0x43, 0x41, 0x50, 0x45, 0x44, 0x5f, 0x55, 0x4e, 0x49, 0x43, 0x4f, 0x44, 0x45, - 0x10, 0x04, 0x2a, 0xd7, 0x86, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, + 0x10, 0x04, 0x2a, 0xf2, 0x86, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x6c, 0x69, 0x62, 0x61, 0x62, 0x61, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x4d, 0x51, 0x50, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x57, 0x53, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x10, 0x03, 0x12, @@ -4758,12 +4761,13 @@ var file_detectors_proto_rawDesc = []byte{ 0x61, 0x73, 0x65, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0x8d, 0x08, 0x12, 0x0e, 0x0a, 0x09, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x72, 0x6f, 0x6f, 0x6d, 0x10, 0x8e, 0x08, 0x12, 0x08, 0x0a, 0x03, 0x4a, 0x57, 0x54, 0x10, 0x8f, 0x08, 0x12, 0x10, 0x0a, 0x0b, 0x4f, - 0x70, 0x65, 0x6e, 0x41, 0x49, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x10, 0x90, 0x08, 0x42, 0x3d, 0x5a, - 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, 0x75, 0x66, - 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, 0x75, 0x66, - 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x62, - 0x2f, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, + 0x70, 0x65, 0x6e, 0x41, 0x49, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x10, 0x90, 0x08, 0x12, 0x19, 0x0a, + 0x14, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x42, 0x61, 0x73, 0x69, + 0x63, 0x41, 0x75, 0x74, 0x68, 0x10, 0x91, 0x08, 0x42, 0x3d, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, + 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, + 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x62, 0x2f, 0x64, 0x65, 0x74, 0x65, + 0x63, 0x74, 0x6f, 0x72, 0x73, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/proto/detectors.proto b/proto/detectors.proto index 1876fe8c8e66..ae0fbffec111 100644 --- a/proto/detectors.proto +++ b/proto/detectors.proto @@ -1050,6 +1050,7 @@ enum DetectorType { Photoroom = 1038; JWT = 1039; OpenAIAdmin = 1040; + ArtifactoryBasicAuth = 1041; } message Result {