Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Detector implementation for Azure API Management Direct Management Key #3938

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Next Next commit
azuredirectmanagementapikey detector implementation and tests
  • Loading branch information
abmussani committed Feb 28, 2025
commit d46cec4bc1ceb4fef4346d468bf048f1997485d1
130 changes: 130 additions & 0 deletions pkg/detectors/azuredirectmanagementkey/azuredirectmanagementkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package azuredirectmanagementkey

import (
"context"
"crypto/hmac"
"crypto/sha512"
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"

regexp "github.com/wasilibs/go-re2"

"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 _ detectors.Detector = (*Scanner)(nil)
var _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil)

var (
defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
urlPat = regexp.MustCompile(`https://([a-zA-Z0-9-]{0,50})\.management\.azure-api\.net`) // https://azure.github.io/PSRule.Rules.Azure/en/rules/Azure.APIM.Name/
keyPat = regexp.MustCompile(`([a-zA-Z0-9+\/-]{86,88}={0,2})`) // Base64-encoded key
)

// 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{".management.azure-api.net"}
}

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

urlMatches := urlPat.FindAllStringSubmatch(dataStr, -1)
keyMatches := keyPat.FindAllStringSubmatch(dataStr, -1)

for _, urlMatch := range urlMatches {
serviceName := urlMatch[1]
for _, keyMatch := range keyMatches {
resMatch := strings.TrimSpace(keyMatch[0])
url := fmt.Sprintf(
"%s/subscriptions/default/resourceGroups/default/providers/Microsoft.ApiManagement/service/%s/apis?api-version=2024-05-01",
urlMatch[0], serviceName,
)
s1 := detectors.Result{
DetectorType: detectorspb.DetectorType_AzureDirectManagementKey,
Raw: []byte(urlMatch[0]),
RawV2: []byte(urlMatch[0] + resMatch),
Redacted: url,
}

if verify {
client := s.client
if client == nil {
client = defaultClient
}

expiry := time.Now().UTC().Add(time.Minute).Format(time.RFC3339Nano)
expiry = expiry[:27] + "Z" // 7 decimals precision for miliseconds
accessToken, err := generateAccessToken(resMatch, expiry)
if err != nil {
continue
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("SharedAccessSignature %s", accessToken))
resp, err := client.Do(req)
if err != nil {
continue
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusOK {
s1.Verified = true
}

}

results = append(results, s1)
if s1.Verified {
break
}
}
}

return results, nil
}

func (s Scanner) IsFalsePositive(_ detectors.Result) (bool, string) {
return false, ""
}

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

func (s Scanner) Description() string {
return "The Azure Management API is a RESTful interface for managing Azure resources programmatically through Azure Resource Manager (ARM), supporting automation with tools like Azure CLI and PowerShell. An Azure Management Direct Access API Key enables secure, non-interactive authentication, allowing direct access to manage resources via Azure Active Directory (AAD)."
}

func generateAccessToken(key, expiry string) (string, error) {
// Construct the string-to-sign
stringToSign := fmt.Sprintf("integration\n%s", expiry)

// Generate HMAC-SHA512 signature
h := hmac.New(sha512.New, []byte(key))
h.Write([]byte(stringToSign))
signature := h.Sum(nil)

// Base64 encode the signature
encodedSignature := base64.StdEncoding.EncodeToString(signature)

// Create the access token
accessToken := fmt.Sprintf("uid=integration&ex=%s&sn=%s", expiry, encodedSignature)
return accessToken, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//go:build detectors
// +build detectors

package azuredirectmanagementkey

import (
"context"
"fmt"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"

"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"

"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
)

func TestAzureDirectManagementAPIKey_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)
}
url := testSecrets.MustGetField("AZUREMANAGEMENTAPI_URL")
secret := testSecrets.MustGetField("AZUREMANAGEMENTAPI_KEY")
inactiveSecret := testSecrets.MustGetField("AZUREMANAGEMENTAPI_KEY_INACTIVE")

type args struct {
ctx context.Context
data []byte
verify bool
}
tests := []struct {
name string
s Scanner
args args
want []detectors.Result
wantErr bool
wantVerificationErr bool
}{
{
name: "found, verified",
s: Scanner{},
args: args{
ctx: ctx,
data: []byte(fmt.Sprintf("You can find a azure management api url %s and key %s within", url, secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_AzureDirectManagementKey,
Verified: true,
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, unverified",
s: Scanner{},
args: args{
ctx: ctx,
data: []byte(fmt.Sprintf("You can find a azure management api url %s and key %s within but not valid", url, inactiveSecret)), // the secret would satisfy the regex but not pass validation
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_AzureDirectManagementKey,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "not found",
s: Scanner{},
args: args{
ctx: ctx,
data: []byte("You cannot find the secret within"),
verify: true,
},
want: nil,
wantErr: false,
wantVerificationErr: 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("AzureDirectManagementAPIKey.FromData() error = %v, wantErr %v", err, tt.wantErr)
return
}
for i := range got {
if len(got[i].Raw) == 0 {
t.Fatalf("no raw secret present: \n %+v", got[i])
}
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
}
}
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "RawV2", "Raw", "Redacted", "verificationError")

if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
t.Errorf("AzureDirectManagementAPIKey.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)
}
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package azuredirectmanagementkey

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"

"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
)

var (
validPattern = `
API_KEY=UJh1Wn7txjls2GPK1YxO9+3tpqQffSfxb+97PmT8j3cSQoXvGa74lCKpBqPeppTHCharbaMeKqKs/H4gA/go1w==
MANAGEMENT_API_URL=https://trufflesecuritytest.management.azure-api.net
SUBSCRIPTION_ID=4b9c0a0b-c862-472a-bca9-3027f18008bc
`
invalidPattern = `
API_KEY=UJh1Wn7txjls2GPK1YxO9+3tpqQffSfxb+97PmT8j3cSQoXvGa74lCKp
MANAGEMENT_API_URL=https://trufflesecuritytest.management.azure-api.net
SUBSCRIPTION_ID=4b9c0a0b-c862-472a-bca9-3027f18008bc
`
)

func TestAzureDirectManagementAPIKey_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})

tests := []struct {
name string
input string
want []string
}{
{
name: "valid pattern",
input: validPattern,
want: []string{"https://trufflesecuritytest.management.azure-api.netUJh1Wn7txjls2GPK1YxO9+3tpqQffSfxb+97PmT8j3cSQoXvGa74lCKpBqPeppTHCharbaMeKqKs/H4gA/go1w=="},
},
{
name: "invalid pattern",
input: invalidPattern,
want: nil,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(matchedDetectors) == 0 {
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
return
}

results, err := d.FromData(context.Background(), false, []byte(test.input))
if err != nil {
t.Errorf("error = %v", err)
return
}

if len(results) != len(test.want) {
if len(results) == 0 {
t.Errorf("did not receive result")
} else {
t.Errorf("expected %d results, only received %d", len(test.want), len(results))
}
return
}

actual := make(map[string]struct{}, len(results))
for _, r := range results {
if len(r.RawV2) > 0 {
actual[string(r.RawV2)] = struct{}{}
} else {
actual[string(r.Raw)] = 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)
}
})
}
}
2 changes: 2 additions & 0 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
@@ -74,6 +74,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_storage"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azurecontainerregistry"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuredevopspersonalaccesstoken"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuredirectmanagementkey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresearchadminkey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresearchquerykey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bannerbear"
@@ -901,6 +902,7 @@ func buildDetectorList() []detectors.Detector {
&azure_batch.Scanner{},
&azurecontainerregistry.Scanner{},
&azuredevopspersonalaccesstoken.Scanner{},
&azuredirectmanagementkey.Scanner{},
// &azurefunctionkey.Scanner{}, // detector is throwing some FPs
&azure_openai.Scanner{},
&azuresearchadminkey.Scanner{},
Loading
Oops, something went wrong.