From 9b0a02b1841016664f191e73e00902464de9c536 Mon Sep 17 00:00:00 2001 From: Richard Gomez Date: Tue, 18 Jun 2024 10:17:27 -0400 Subject: [PATCH] feat(azure): update sp detector --- .../serviceprincipal/serviceprincipal.go | 94 ------ .../azure_entra/serviceprincipal/sp.go | 116 ++++++++ .../azure_entra/serviceprincipal/v1/spv1.go | 89 ++++++ .../spv1_integration_test.go} | 2 +- .../serviceprincipal/v1/spv1_test.go | 120 ++++++++ .../azure_entra/serviceprincipal/v2/spv2.go | 281 ++++++++++++++++++ .../serviceprincipal/v2/spv2_test.go | 72 +++++ pkg/engine/defaults.go | 6 +- 8 files changed, 683 insertions(+), 97 deletions(-) delete mode 100644 pkg/detectors/azure_entra/serviceprincipal/serviceprincipal.go create mode 100644 pkg/detectors/azure_entra/serviceprincipal/sp.go create mode 100644 pkg/detectors/azure_entra/serviceprincipal/v1/spv1.go rename pkg/detectors/azure_entra/serviceprincipal/{serviceprincipal_test.go => v1/spv1_integration_test.go} (98%) create mode 100644 pkg/detectors/azure_entra/serviceprincipal/v1/spv1_test.go create mode 100644 pkg/detectors/azure_entra/serviceprincipal/v2/spv2.go create mode 100644 pkg/detectors/azure_entra/serviceprincipal/v2/spv2_test.go diff --git a/pkg/detectors/azure_entra/serviceprincipal/serviceprincipal.go b/pkg/detectors/azure_entra/serviceprincipal/serviceprincipal.go deleted file mode 100644 index 368159637785..000000000000 --- a/pkg/detectors/azure_entra/serviceprincipal/serviceprincipal.go +++ /dev/null @@ -1,94 +0,0 @@ -package azure_entra_serviceprincipal - -import ( - "context" - "fmt" - "strings" - - regexp "github.com/wasilibs/go-re2" - - "github.com/Azure/go-autorest/autorest/azure/auth" - regexp "github.com/wasilibs/go-re2" - - "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" - "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" -) - -type Scanner struct { - detectors.DefaultMultiPartCredentialProvider -} - -// Ensure the Scanner satisfies the interface at compile time. -var _ detectors.Detector = (*Scanner)(nil) - -func mustFmtPat(id, pat string) *regexp.Regexp { - combinedID := strings.ReplaceAll(id, "_", "") + "|" + id - return regexp.MustCompile(fmt.Sprintf(pat, combinedID)) -} - -var ( - // TODO: Azure storage access keys and investigate other types of creds. - - // Azure App Oauth - idPatFmt = `(?i)(%s).{0,20}([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})` - clientIDPat = mustFmtPat("client_id", idPatFmt) - tenantIDPat = mustFmtPat("tenant_id", idPatFmt) - - // TODO: support old patterns - secretPatFmt = `(?i)(%s).{0,20}([a-z0-9_\.\-~]{34})` - clientSecretPat = mustFmtPat("client_secret", secretPatFmt) -) - -// 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{"azure"} -} - -// FromData will find and optionally verify Azure 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) - - clientSecretMatches := clientSecretPat.FindAllStringSubmatch(dataStr, -1) - for _, clientSecret := range clientSecretMatches { - tenantIDMatches := tenantIDPat.FindAllStringSubmatch(dataStr, -1) - for _, tenantID := range tenantIDMatches { - clientIDMatches := clientIDPat.FindAllStringSubmatch(dataStr, -1) - for _, clientID := range clientIDMatches { - res := detectors.Result{ - DetectorType: detectorspb.DetectorType_Azure, - Raw: []byte(clientSecret[2]), - RawV2: []byte(clientID[2] + clientSecret[2] + tenantID[2]), - Redacted: clientID[2], - ExtraData: map[string]string{ - "rotation_guide": "https://howtorotate.com/docs/tutorials/azure/", - }, - } - - if verify { - cred := auth.NewClientCredentialsConfig(clientID[2], clientSecret[2], tenantID[2]) - token, err := cred.ServicePrincipalToken() - if err != nil { - continue - } - err = token.RefreshWithContext(ctx) - if err == nil { - res.Verified = true - } - } - - results = append(results, res) - } - } - } - - return results, nil -} - -func (s Scanner) Type() detectorspb.DetectorType { - return detectorspb.DetectorType_Azure -} - -func (s Scanner) Description() string { - return "Azure is a cloud service offering a wide range of services including compute, analytics, storage, and networking. Azure credentials can be used to access and manage these services." -} diff --git a/pkg/detectors/azure_entra/serviceprincipal/sp.go b/pkg/detectors/azure_entra/serviceprincipal/sp.go new file mode 100644 index 000000000000..07e7f841042c --- /dev/null +++ b/pkg/detectors/azure_entra/serviceprincipal/sp.go @@ -0,0 +1,116 @@ +package serviceprincipal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrSecretInvalid = errors.New("invalid client secret provided") + ErrSecretExpired = errors.New("the provided secret is expired") + ErrTenantNotFound = errors.New("tenant not found") + ErrClientNotFoundInTenant = errors.New("application was not found in tenant") +) + +type TokenOkResponse struct { + AccessToken string `json:"access_token"` +} + +type TokenErrResponse struct { + Error string `json:"error"` + Description string `json:"error_description"` +} + +// VerifyCredentials attempts to get a token using the provided client credentials. +// See: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow#get-a-token +func VerifyCredentials(ctx context.Context, client *http.Client, tenantId string, clientId string, clientSecret string) (bool, map[string]string, error) { + data := url.Values{} + data.Set("client_id", clientId) + //data.Set("scope", "https://management.core.windows.net/.default") + data.Set("scope", "https://graph.microsoft.com/.default") + data.Set("client_secret", clientSecret) + data.Set("grant_type", "client_credentials") + + tokenUrl := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantId) + encodedData := data.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenUrl, strings.NewReader(encodedData)) + if err != nil { + return false, nil, nil + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Length", strconv.Itoa(len(encodedData))) + + res, err := client.Do(req) + if err != nil { + return false, nil, err + } + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + if res.StatusCode == http.StatusOK { + var okResp TokenOkResponse + + if err := json.NewDecoder(res.Body).Decode(&okResp); err != nil { + return false, nil, err + } + + extraData := map[string]string{ + "rotation_guide": "https://howtorotate.com/docs/tutorials/azure/", + "tenant": tenantId, + "client": clientId, + } + + // Add claims from the access token. + if token, _ := jwt.Parse(okResp.AccessToken, nil); token != nil { + claims := token.Claims.(jwt.MapClaims) + + if app := claims["app_displayname"]; app != nil { + extraData["application"] = fmt.Sprint(app) + } + } + return true, extraData, nil + } else { + var errResp TokenErrResponse + if err := json.NewDecoder(res.Body).Decode(&errResp); err != nil { + return false, nil, err + } + + switch res.StatusCode { + case http.StatusBadRequest, http.StatusUnauthorized: + // Error codes can be looked up by removing the `AADSTS` prefix. + // https://login.microsoftonline.com/error?code=9002313 + d := errResp.Description + switch { + case strings.HasPrefix(d, "AADSTS700016:"): + // https://login.microsoftonline.com/error?code=700016 + return false, nil, ErrClientNotFoundInTenant + case strings.HasPrefix(d, "AADSTS7000215:"): + // https://login.microsoftonline.com/error?code=7000215 + return false, nil, ErrSecretInvalid + case strings.HasPrefix(d, "AADSTS7000222:"): + // The secret has expired. + // https://login.microsoftonline.com/error?code=7000222 + return false, nil, ErrSecretExpired + case strings.HasPrefix(d, "AADSTS90002:"): + // https://login.microsoftonline.com/error?code=90002 + return false, nil, ErrTenantNotFound + default: + return false, nil, fmt.Errorf("unexpected error '%s': %s", errResp.Error, errResp.Description) + } + default: + return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) + } + } +} diff --git a/pkg/detectors/azure_entra/serviceprincipal/v1/spv1.go b/pkg/detectors/azure_entra/serviceprincipal/v1/spv1.go new file mode 100644 index 000000000000..cecdeac101ed --- /dev/null +++ b/pkg/detectors/azure_entra/serviceprincipal/v1/spv1.go @@ -0,0 +1,89 @@ +package v1 + +import ( + "context" + "net/http" + + regexp "github.com/wasilibs/go-re2" + + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_entra" + v2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_entra/serviceprincipal/v2" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" +) + +type Scanner struct { + client *http.Client + detectors.DefaultMultiPartCredentialProvider +} + +// Ensure the Scanner satisfies the interface at compile time. +var _ interface { + detectors.Detector + detectors.Versioner +} = (*Scanner)(nil) + +var ( + defaultClient = common.SaneHttpClient() + // TODO: Azure storage access keys and investigate other types of creds. + // https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate + // https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow#third-case-access-token-request-with-a-federated-credential + //clientSecretPat = regexp.MustCompile(`(?i)(?:secret|password| -p[ =]).{0,40}?([a-z0-9~@_\-[\]:.?]{32,34})`) + secretPat = regexp.MustCompile(`(?i)(?:secret|password| -p[ =]).{0,80}[^A-Za-z0-9!#$%&()*+,\-./:;<=>?@[\\\]^_{|}~]([A-Za-z0-9!#$%&()*+,\-./:;<=>?@[\\\]^_{|}~]{31,34})[^A-Za-z0-9!#$%&()*+,\-./:;<=>?@[\\\]^_{|}~]`) +) + +func (s Scanner) Version() int { + return 1 +} + +// 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{"azure", "az", "entra", "msal", "login.microsoftonline.com", ".onmicrosoft.com"} +} + +// FromData will find and optionally verify Azure 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) + + clientSecrets := findSecretMatches(dataStr) + if len(clientSecrets) == 0 { + return + } + clientIds := azure_entra.FindClientIdMatches(dataStr) + if len(clientIds) == 0 { + return + } + tenantIds := azure_entra.FindTenantIdMatches(dataStr) + + client := s.client + if client == nil { + client = defaultClient + } + processedResults := v2.ProcessData(ctx, clientSecrets, clientIds, tenantIds, verify, client) + for _, result := range processedResults { + results = append(results, result) + } + return results, nil +} + +func (s Scanner) Type() detectorspb.DetectorType { + return detectorspb.DetectorType_Azure +} + +// region Helper methods. +func findSecretMatches(data string) map[string]struct{} { + uniqueMatches := make(map[string]struct{}) + for _, match := range secretPat.FindAllStringSubmatch(data, -1) { + m := match[1] + // Ignore secrets that are handled by the V2 detector. + if v2.SecretPat.MatchString(m) { + continue + } + uniqueMatches[m] = struct{}{} + } + return uniqueMatches +} + +//endregion diff --git a/pkg/detectors/azure_entra/serviceprincipal/serviceprincipal_test.go b/pkg/detectors/azure_entra/serviceprincipal/v1/spv1_integration_test.go similarity index 98% rename from pkg/detectors/azure_entra/serviceprincipal/serviceprincipal_test.go rename to pkg/detectors/azure_entra/serviceprincipal/v1/spv1_integration_test.go index 7e963b10ce60..ba76a40c6ed4 100644 --- a/pkg/detectors/azure_entra/serviceprincipal/serviceprincipal_test.go +++ b/pkg/detectors/azure_entra/serviceprincipal/v1/spv1_integration_test.go @@ -1,7 +1,7 @@ //go:build detectors // +build detectors -package azure_entra_serviceprincipal +package v1 import ( "context" diff --git a/pkg/detectors/azure_entra/serviceprincipal/v1/spv1_test.go b/pkg/detectors/azure_entra/serviceprincipal/v1/spv1_test.go new file mode 100644 index 000000000000..bf74db801760 --- /dev/null +++ b/pkg/detectors/azure_entra/serviceprincipal/v1/spv1_test.go @@ -0,0 +1,120 @@ +package v1 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +type testCase struct { + Input string + Expected map[string]struct{} +} + +func Test_FindClientSecretMatches(t *testing.T) { + cases := map[string]testCase{ + "client_secret": { + Input: ` "TenantId": "3d7e0652-b03d-4ed2-bf86-f1299cecde17", + "ClientSecret": "gHduiL_j6t4b6DG?Qr-G6M@IOS?mX3B9",`, + Expected: map[string]struct{}{"gHduiL_j6t4b6DG?Qr-G6M@IOS?mX3B9": {}}, + }, + "client_secret1": { + Input: ` public static string clientId = "413ff05b-6d54-41a7-9271-9f964bc10624"; + public static string clientSecret = "k72~odcN_6TbVh5D~19_1Qkj~87trteArL"; + + private const string `, + Expected: map[string]struct{}{"k72~odcN_6TbVh5D~19_1Qkj~87trteArL": {}}, + }, + "client_secret2": { + Input: ` "azClientSecret": "2bWD_tu3~9B0_.R0W3BFJN-Hu_xjfR8EL5", + "kvVaultUri": "https://corp.vault.azure.net/",`, + Expected: map[string]struct{}{"2bWD_tu3~9B0_.R0W3BFJN-Hu_xjfR8EL5": {}}, + }, + "client_secret3": { + Input: `# COMMAND ---------- + +clientID = "193e3d24-8d04-404c-95a9-074efaa83147" +tenantID = "28241a04-7ac0-44f1-a996-84dc181f9861" +secret = "a2djRWTXDS1iMbThoK.C7e:yVsUdL3[:"`, + Expected: map[string]struct{}{"a2djRWTXDS1iMbThoK.C7e:yVsUdL3[:": {}}, + }, + "client_secret4": { + Input: `tenantID = "9f37a392-g0ae-1280-9796-f1864210effc" +secret = "s.1_56k~5jmRDm23y.dTg5_XjTAcRjCbH." + +# COMMAND ---------- + +configs = {"fs.azure.account.auth.type": "OAuth"`, + Expected: map[string]struct{}{"s.1_56k~5jmRDm23y.dTg5_XjTAcRjCbH.": {}}, + }, + "client_secret5": { + Input: `public class HardcodedAzureCredentials { + private final String clientId = "81734019-15a3-50t8-3253-5abe78abc3a1"; + private final String username = "username@example.onmicrosoft.com"; + private final String clientSecret = "1n1.qAc~3Q-1t38aF79Xzv5AUEfR5-ct3_";`, + Expected: map[string]struct{}{"1n1.qAc~3Q-1t38aF79Xzv5AUEfR5-ct3_": {}}, + }, + // https://github.com/kedacore/keda/blob/main/pkg/scalers/azure_log_analytics_scaler_test.go + "client_secret6": { + Input: `const ( + tenantID = "d248da64-0e1e-4f79-b8c6-72ab7aa055eb" + clientID = "41826dd4-9e0a-4357-a5bd-a88ad771ea7d" + clientSecret = "U6DtAX5r6RPZxd~l12Ri3X8J9urt5Q-xs" + workspaceID = "074dd9f8-c368-4220-9400-acb6e80fc325"`, + Expected: map[string]struct{}{"U6DtAX5r6RPZxd~l12Ri3X8J9urt5Q-xs": {}}, + }, + "client_secret7": { + Input: ` "AZUREAD-AKS-APPID-SECRET": "xW25Gt-Mf0.ue3jFqE68jtFqtt-4L_8R51", + "AZUREAD-AKS-TENANTID": "d3a761f8-e7ea-473a-b907-1e7b3ef92aa9",`, + Expected: map[string]struct{}{"xW25Gt-Mf0.ue3jFqE68jtFqtt-4L_8R51": {}}, + }, + "client_secret8": { + Input: ` "AZUREAD-AKS-APPID-SECRET": "8w__IGsaY.6g6jUxb1.pPGK262._pgX.q-",`, + Expected: map[string]struct{}{"8w__IGsaY.6g6jUxb1.pPGK262._pgX.q-": {}}, + }, + //"client_secret6": { + // Input: ``, + // Expected: map[string]struct{}{"": {}}, + //}, + + "password": { + Input: `# Login using Service Principal +$ApplicationId = "5cec5dfb-0ac4-4938-b477-3f9638881b93" +$SecuredPassword = ConvertTo-SecureString -String "gHduiL_j6t4b6DG?Qr-G6M@IOS?mX3B9" -AsPlainText -Force +$Credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $ApplicationId, $SecuredPassword`, + Expected: map[string]struct{}{"gHduiL_j6t4b6DG?Qr-G6M@IOS?mX3B9": {}}, + }, + + // False positives + "placeholder_secret": { + Input: `- Log in with a service principal using a client secret: + +az login --service-principal --username {{http://azure-cli-service-principal}} --password {{secret}} --tenant {{someone.onmicrosoft.com}}`, + Expected: nil, + }, + //"client_secret3": { + // Input: ``, + // Expected: map[string]struct{}{ + // "": {}, + // }, + //}, + } + + for name, test := range cases { + t.Run(name, func(t *testing.T) { + matches := findSecretMatches(test.Input) + if len(matches) == 0 { + if len(test.Expected) != 0 { + t.Fatalf("no matches found, expected: %v", test.Expected) + return + } else { + return + } + } + + if diff := cmp.Diff(test.Expected, matches); diff != "" { + t.Errorf("%s diff: (-want +got)\n%s", name, diff) + } + }) + } +} diff --git a/pkg/detectors/azure_entra/serviceprincipal/v2/spv2.go b/pkg/detectors/azure_entra/serviceprincipal/v2/spv2.go new file mode 100644 index 000000000000..a2fcb6d73421 --- /dev/null +++ b/pkg/detectors/azure_entra/serviceprincipal/v2/spv2.go @@ -0,0 +1,281 @@ +package v2 + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_entra" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_entra/serviceprincipal" + + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" +) + +type Scanner struct { + client *http.Client + detectors.DefaultMultiPartCredentialProvider +} + +// Ensure the Scanner satisfies the interface at compile time. +var _ interface { + detectors.Detector + detectors.Versioner +} = (*Scanner)(nil) + +var ( + defaultClient = common.SaneHttpClient() + + SecretPat = regexp.MustCompile(`(?:[^a-zA-Z0-9_~.-]|\A)([a-zA-Z0-9_~.-]{3}\dQ~[a-zA-Z0-9_~.-]{31,34})(?:[^a-zA-Z0-9_~.-]|\z)`) + //clientSecretPat = regexp.MustCompile(`(?:[^a-zA-Z0-9_~.-]|\A)([a-zA-Z0-9_~.-]{3}\dQ~[a-zA-Z0-9_~.-]{31,34})(?:[^a-zA-Z0-9_~.-]|\z)|(?:secret|password| -p[ =]).{0,80}[^A-Za-z0-9!#$%&()*+,\-./:;<=>?@[\\\]^_{|}~]([A-Za-z0-9!#$%&()*+,\-./:;<=>?@[\\\]^_{|}~]{31,34})[^A-Za-z0-9!#$%&()*+,\-./:;<=>?@[\\\]^_{|}~]`) +) + +func (s Scanner) Version() int { + return 2 +} + +// 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{"q~"} +} + +// FromData will find and optionally verify Azure 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) + + clientSecrets := findSecretMatches(dataStr) + if len(clientSecrets) == 0 { + return results, nil + } + clientIds := azure_entra.FindClientIdMatches(dataStr) + tenantIds := azure_entra.FindTenantIdMatches(dataStr) + + client := s.client + if client == nil { + client = defaultClient + } + + processedResults := ProcessData(ctx, clientSecrets, clientIds, tenantIds, verify, client) + for _, result := range processedResults { + results = append(results, result) + } + return results, nil +} + +func ProcessData(ctx context.Context, clientSecrets, clientIds, tenantIds map[string]struct{}, verify bool, client *http.Client) (results []detectors.Result) { + invalidClientsForTenant := make(map[string]map[string]struct{}) + +SecretLoop: + for clientSecret := range clientSecrets { + var ( + r *detectors.Result + clientId string + tenantId string + ) + + ClientLoop: + for cId := range clientIds { + clientId = cId + for tId := range tenantIds { + tenantId = tId + + // Skip known invalid tenants. + invalidClients := invalidClientsForTenant[tenantId] + if invalidClients == nil { + invalidClients = map[string]struct{}{} + invalidClientsForTenant[tenantId] = invalidClients + } + if _, ok := invalidClients[clientId]; ok { + continue + } + + if verify { + if !isValidTenant(ctx, client, tenantId) { + // Tenant doesn't exist + delete(tenantIds, tenantId) + continue + } + + isVerified, extraData, verificationErr := serviceprincipal.VerifyCredentials(ctx, client, tenantId, clientId, clientSecret) + // Handle errors. + if verificationErr != nil { + switch { + case errors.Is(verificationErr, serviceprincipal.ErrSecretInvalid): + continue ClientLoop + case errors.Is(verificationErr, serviceprincipal.ErrSecretExpired): + continue SecretLoop + case errors.Is(verificationErr, serviceprincipal.ErrTenantNotFound): + // Tenant doesn't exist. This shouldn't happen with the check above. + delete(tenantIds, tenantId) + continue + case errors.Is(verificationErr, serviceprincipal.ErrClientNotFoundInTenant): + // Tenant is valid but the ClientID doesn't exist. + invalidClients[clientId] = struct{}{} + continue + } + } + + // The result is verified or there's only one associated client and tenant. + if isVerified || (len(clientIds) == 1 && len(tenantIds) == 1) { + r = createResult(tenantId, clientId, clientSecret, isVerified, extraData, verificationErr) + break ClientLoop + } + + // The result may be valid for another client/tenant. + // + // + //// https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-access-token-with-a-client_secret + //cred := auth.NewClientCredentialsConfig(clientId, clientSecret, tenantId) + //token, err := cred.ServicePrincipalToken() + //if err != nil { + // // This can only fail if a value is empty, which shouldn't be possible. + // continue + //} + // + //err = token.Refresh() + //if err != nil { + // var refreshError adal.TokenRefreshError + // if ok := errors.As(err, &refreshError); ok { + // resp := refreshError.Response() + // defer func() { + // // Ensure we drain the response body so this connection can be reused. + // _, _ = io.Copy(io.Discard, resp.Body) + // _ = resp.Body.Close() + // }() + // + // status := resp.StatusCode + // errStr := refreshError.Error() + // if status == 400 { + // if strings.Contains(errStr, `"error_description":"AADSTS90002:`) { + // // Tenant doesn't exist + // delete(tenantIds, tenantId) + // continue + // } else if strings.Contains(errStr, `"error_description":"AADSTS700016:`) { + // // Tenant is valid but the ClientID doesn't exist. + // invalidTenantsForClientId[clientId] = append(invalidTenantsForClientId[clientId], tenantId) + // continue + // } else { + // // Unexpected error. + // r.SetVerificationError(refreshError, clientSecret) + // break + // } + // } else if status == 401 { + // // Tenant exists and the clientID is valid, but something is wrong. + // if strings.Contains(errStr, `"error_description":"AADSTS7000215:`) { + // // Secret is not valid. + // setValidTenantIdForClientId(clientId, tenantId, tenantIds, invalidTenantsForClientId) + // continue IdLoop + // } else if strings.Contains(errStr, `"error_description":"AADSTS7000222:`) { + // // The secret is expired. + // setValidTenantIdForClientId(clientId, tenantId, tenantIds, invalidTenantsForClientId) + // continue SecretLoop + // } else { + // // TODO: Investigate if it's possible to get a 401 with a valid id/secret. + // r.SetVerificationError(refreshError, clientSecret) + // break + // } + // } else { + // // Unexpected status code. + // r.SetVerificationError(refreshError, clientSecret) + // break + // } + // } else { + // // Unexpected error. + // r.SetVerificationError(err, clientSecret) + // break + // } + //} else { + // r.Verified = true + // r.ExtraData = map[string]string{ + // "token": token.OAuthToken(), + // } + // setValidTenantIdForClientId(clientId, tenantId, tenantIds, invalidTenantsForClientId) + // break + //} + } + } + } + + if r == nil { + // Only include the clientId and tenantId if we're confident which one it is. + if len(clientIds) != 1 { + clientId = "" + } + if len(tenantIds) != 1 { + tenantId = "" + } + r = createResult(tenantId, clientId, clientSecret, false, nil, nil) + } + + results = append(results, *r) + } + return results +} + +func createResult(tenantId string, clientId string, clientSecret string, verified bool, extraData map[string]string, err error) *detectors.Result { + r := &detectors.Result{ + DetectorType: detectorspb.DetectorType_Azure, + Raw: []byte(clientSecret), + ExtraData: extraData, + Verified: verified, + Redacted: clientSecret[:5] + "...", + } + r.SetVerificationError(err, clientSecret) + + // Tenant ID is required for verification, but it may not always be present. + // e.g., ACR or Azure SQL use client id+secret without tenant. + if clientId != "" && tenantId != "" { + var sb strings.Builder + sb.WriteString(`{`) + sb.WriteString(`"clientSecret":"` + clientSecret + `"`) + sb.WriteString(`,"clientId":"` + clientId + `"`) + sb.WriteString(`,"tenantId":"` + tenantId + `"`) + sb.WriteString(`}`) + r.RawV2 = []byte(sb.String()) + } + + return r +} + +func isValidTenant(ctx context.Context, client *http.Client, tenant string) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://login.microsoftonline.com/%s/.well-known/openid-configuration", tenant), nil) + if err != nil { + return false + } + res, err := client.Do(req) + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + if res.StatusCode == 200 { + return true + } else if res.StatusCode == 400 { + fmt.Printf("Invalid tenant: %s\n", tenant) + return false + } else { + fmt.Printf("[azure] Unexpected status code: %d for %s\n", res.StatusCode, tenant) + return false + } +} + +func (s Scanner) Type() detectorspb.DetectorType { + return detectorspb.DetectorType_Azure +} + +// region Helper methods. +func findSecretMatches(data string) map[string]struct{} { + uniqueMatches := make(map[string]struct{}) + for _, match := range SecretPat.FindAllStringSubmatch(data, -1) { + uniqueMatches[match[1]] = struct{}{} + } + return uniqueMatches +} + +//endregion diff --git a/pkg/detectors/azure_entra/serviceprincipal/v2/spv2_test.go b/pkg/detectors/azure_entra/serviceprincipal/v2/spv2_test.go new file mode 100644 index 000000000000..68eb128368b1 --- /dev/null +++ b/pkg/detectors/azure_entra/serviceprincipal/v2/spv2_test.go @@ -0,0 +1,72 @@ +package v2 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +type testCase struct { + Input string + Expected map[string]struct{} +} + +func Test_FindClientSecretMatches(t *testing.T) { + cases := map[string]testCase{ + "secret": { + Input: `servicePrincipal: + tenantId: "608e4ac4-2ca8-40dd-a046-4064540a1cde" + clientId: "1474bfe8-663c-486e-9daf-f1f580302218" + clientSecret: "R028Q~ZOKzgCYyhr1ZJNNKhP8gUcD3Dpy2jMqaXf" +agentImage: "karbar.azurecr.io/kar-agent"`, + Expected: map[string]struct{}{ + "R028Q~ZOKzgCYyhr1ZJNNKhP8gUcD3Dpy2jMqaXf": {}, + }, + }, + "secret_start_with_dash": { + Input: `azure: + active-directory: + enabled: true + profile: + tenant-id: 11111111-1111-1111-1111-111111111111 + credential: + client-id: 00000000-0000-0000-0000-000000000000 + client-secret: -bs8Q~F9mPSWiDihY0NIpcQjAWoUoQ.c-seM-c0_`, + Expected: map[string]struct{}{ + "-bs8Q~F9mPSWiDihY0NIpcQjAWoUoQ.c-seM-c0_": {}, + }, + }, + "secret_end_with_dash": { + Input: `OPENID_CLIENT_ID=8595f61a-109a-497d-8c8f-566b733e95fe +OPENID_CLIENT_SECRET=aZ78Q~C~--E4dgsHZklBWtAw0mdajUHAaXXG5cq- +OPENID_GRANT_TYPE=client_credentials`, + Expected: map[string]struct{}{ + "aZ78Q~C~--E4dgsHZklBWtAw0mdajUHAaXXG5cq-": {}, + }, + }, + "client_secret": { + Input: ` "RequestBody": "client_id=4cb7565b-9ff0-49ed-b317-4dace4a70396\u0026grant_type=client_credentials\u0026client_info=1\u0026client_secret=-6s8Q~.Q9CKMOXHGs_BA3ig2wUzyDRyulhWEOc3u\u0026claims=%7B%22access_token%22%3A\u002B%7B%22xms_cc%22%3A\u002B%7B%22values%22%3A\u002B%5B%22CP1%22%5D%7D%7D%7D\u0026scope=https%3A%2F%2Fmanagement.azure.com%2F.default",`, + Expected: map[string]struct{}{ + "-6s8Q~.Q9CKMOXHGs_BA3ig2wUzyDRyulhWEOc3u": {}, + }, + }, + } + + for name, test := range cases { + t.Run(name, func(t *testing.T) { + matches := findSecretMatches(test.Input) + if len(matches) == 0 { + if len(test.Expected) != 0 { + t.Fatalf("no matches found, expected: %v", test.Expected) + return + } else { + return + } + } + + if diff := cmp.Diff(test.Expected, matches); diff != "" { + t.Errorf("%s diff: (-want +got)\n%s", name, diff) + } + }) + } +} diff --git a/pkg/engine/defaults.go b/pkg/engine/defaults.go index f82312a22205..7baaa0790a47 100644 --- a/pkg/engine/defaults.go +++ b/pkg/engine/defaults.go @@ -64,7 +64,8 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/aylien" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/ayrshare" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_batch" - azure_entra_serviceprincipal "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_entra/serviceprincipal" + azure_entra_serviceprincipal_v1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_entra/serviceprincipal/v1" + azure_entra_serviceprincipal_v2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_entra/serviceprincipal/v2" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azurecontainerregistry" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuredevopspersonalaccesstoken" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresearchadminkey" @@ -1610,7 +1611,8 @@ func DefaultDetectors() []detectors.Detector { portainertoken.Scanner{}, pagarme.Scanner{}, planetscaledb.Scanner{}, - azure_entra_serviceprincipal.Scanner{}, + &azure_entra_serviceprincipal_v1.Scanner{}, + &azure_entra_serviceprincipal_v2.Scanner{}, azurestorage.Scanner{}, azurecontainerregistry.Scanner{}, azurebatch.Scanner{},