Skip to content

Commit

Permalink
feat(detectors): create azure refresh token
Browse files Browse the repository at this point in the history
  • Loading branch information
rgmz committed Nov 20, 2024
1 parent 9d99de8 commit c15da95
Show file tree
Hide file tree
Showing 7 changed files with 634 additions and 10 deletions.
308 changes: 308 additions & 0 deletions pkg/detectors/azure_entra/refreshtoken/refreshtoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
package azure_entra_refreshtoken

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/golang-jwt/jwt/v4"
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"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
)

type Scanner struct {
client *http.Client
}

// Ensure the Scanner satisfies the interface at compile time.
var _ interface {
detectors.Detector
detectors.MaxSecretSizeProvider
detectors.StartOffsetProvider
} = (*Scanner)(nil)

var (
defaultClient = common.SaneHttpClient()

refreshTokenPat = regexp.MustCompile(`\b0\.A[\w-]{50,}\.Ag[\w-]{250,}`)
)

// 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{"0.A"}
}

func (Scanner) MaxSecretSize() int64 { return 2048 }

func (Scanner) StartOffset() int64 { return 4096 }

// FromData will find and optionally verify Azure RefreshToken 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)

tokenMatches := findTokenMatches(dataStr)
if len(tokenMatches) == 0 {
return
}
clientMatches := azure_entra.FindClientIdMatches(dataStr)
if len(clientMatches) == 0 {
clientMatches[defaultClientId] = struct{}{}
}
tenantMatches := azure_entra.FindTenantIdMatches(dataStr)
if len(tenantMatches) == 0 {
tenantMatches[defaultTenantId] = struct{}{}
}

return s.processMatches(ctx, tokenMatches, clientMatches, tenantMatches, verify), err
}

func (s Scanner) processMatches(ctx context.Context, refreshTokens, clientIds, tenantIds map[string]struct{}, verify bool) (results []detectors.Result) {
invalidClientsForTenant := make(map[string]map[string]struct{})
validTenants := make(map[string]struct{})

TokenLoop:
for token := range refreshTokens {
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 {
client := s.client
if client == nil {
client = defaultClient
}

if _, ok := validTenants[tenantId]; !ok {
if azure_entra.TenantExists(ctx, client, tenantId) {
validTenants[tenantId] = struct{}{}
} else {
fmt.Printf("Tenant doesn't exist: '%v'\n", tenantId)
delete(tenantIds, tenantId)
continue
}
}

isVerified, extraData, verificationErr := verifyMatch(ctx, client, token, clientId, tenantId)
// Handle errors.
if verificationErr != nil {
if errors.Is(verificationErr, ErrTenantNotFound) {
// Tenant doesn't exist. This shouldn't happen with the check above.
delete(tenantIds, tenantId)
continue
} else if errors.Is(verificationErr, ErrClientNotFoundInTenant) {
// Tenant is valid but the ClientID doesn't exist.
invalidClients[clientId] = struct{}{}
continue
} else if errors.Is(verificationErr, ErrTokenExpired) {
continue TokenLoop
}
}

// 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, token, isVerified, extraData, verificationErr)
break ClientLoop
}

// The result may be valid for another client/tenant.
}
}
}

if r == nil {
// Only include the clientId and tenantId if we're confident which one it is.
if len(clientIds) != 1 || clientId == defaultClientId {
clientId = ""
}
if len(tenantIds) != 1 || tenantId == defaultTenantId {
tenantId = ""
}
r = createResult(token, clientId, tenantId, false, nil, nil)
}

results = append(results, *r)
}
return results
}

const defaultTenantId = "common"
const defaultClientId = "d3590ed6-52b3-4102-aeff-aad2292ab01c" // Microsoft Office

var (
ErrTokenExpired = errors.New("token expired")
ErrTenantNotFound = errors.New("tenant not found")
ErrClientNotFoundInTenant = errors.New("application was not found in tenant")
)

// https://learn.microsoft.com/en-us/advertising/guides/authentication-oauth-get-tokens?view=bingads-13#refresh-accesstoken
func verifyMatch(ctx context.Context, client *http.Client, refreshToken string, clientId string, tenantId string) (bool, map[string]string, error) {
data := url.Values{}
data.Set("client_id", clientId)
data.Set("scope", "https://graph.microsoft.com/.default")
data.Set("refresh_token", refreshToken)
data.Set("grant_type", "refresh_token")

tokenUrl := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantId)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenUrl, bytes.NewBufferString(data.Encode()))
if err != nil {
return false, nil, nil
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

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 successResponse
if err := json.NewDecoder(res.Body).Decode(&okResp); err != nil {
return false, nil, err
}

extraData := map[string]string{
"Tenant": tenantId,
"Client": clientId,
"Scope": okResp.Scope,
}

// Add claims from the access token.
token, _ := jwt.Parse(okResp.AccessToken, nil)
if token != nil {
claims := token.Claims.(jwt.MapClaims)

if app := fmt.Sprint(claims["app_displayname"]); app != "" {
extraData["Application"] = app
}

// The user information can be in a few claims.
switch {
case claims["email"] != nil:
extraData["User"] = fmt.Sprint(claims["email"])
case claims["upn"] != nil:
extraData["User"] = fmt.Sprint(claims["upn"])
case claims["unique_name"]:
extraData["User"] = fmt.Sprint(claims["unique_name"])
}
}
return true, extraData, nil
} else {
var errResp errorResponse
if err := json.NewDecoder(res.Body).Decode(&errResp); err != nil {
return false, nil, err
}

switch res.StatusCode {
case http.StatusBadRequest:
// 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, "AADSTS70008:"):
// https://login.microsoftonline.com/error?code=70008
return false, nil, ErrTokenExpired
case strings.HasPrefix(d, "AADSTS700016:"):
// https://login.microsoftonline.com/error?code=700016
return false, nil, ErrClientNotFoundInTenant
case strings.HasPrefix(d, "AADSTS90002:"):
// https://login.microsoftonline.com/error?code=90002
return false, nil, ErrTenantNotFound
case strings.HasPrefix(d, "AADSTS9002313"):
// This seems to be a generic "invalid token" error code.
// 'invalid_grant': AADSTS9002313: Invalid request. Request is malformed or invalid.
return false, nil, nil
default:
return false, nil, fmt.Errorf("unexpected error '%s': %s", errResp.Error, errResp.Description)
}
case http.StatusUnauthorized:
// The secret is determinately not verified (nothing to do)
return false, nil, nil
default:
return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
}
}
}

type successResponse struct {
Scope string `json:"scope"`
AccessToken string `json:"access_token"`
}

type errorResponse struct {
Error string `json:"error"`
Description string `json:"error_description"`
}

func (s Scanner) Type() detectorspb.DetectorType {
return detectorspb.DetectorType_AzureRefreshToken
}

func (s Scanner) Description() string {
return "Azure Entra ID refresh tokens provide long-lasting access to an account."
}

// region Helper methods.
func findTokenMatches(data string) map[string]struct{} {
uniqueMatches := make(map[string]struct{})
for _, match := range refreshTokenPat.FindAllStringSubmatch(data, -1) {
uniqueMatches[match[0]] = struct{}{}
}
return uniqueMatches
}

func createResult(refreshToken, clientId, tenantId string, verified bool, extraData map[string]string, err error) *detectors.Result {
r := &detectors.Result{
DetectorType: detectorspb.DetectorType_AzureRefreshToken,
Raw: []byte(refreshToken),
ExtraData: extraData,
Verified: verified,
}
r.SetVerificationError(err, refreshToken)

if clientId != "" && tenantId != "" {
var sb strings.Builder
sb.WriteString(`{`)
sb.WriteString(`"refreshToken":"` + refreshToken + `"`)
sb.WriteString(`,"clientId":"` + clientId + `"`)
sb.WriteString(`,"tenantId":"` + tenantId + `"`)
sb.WriteString(`}`)
r.RawV2 = []byte(sb.String())
}

return r
}

//endregion
Loading

0 comments on commit c15da95

Please sign in to comment.