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

[Feature] Detector implementation for Azure Configuration Connection String Key #3939

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package azureappconfigconnectionstring

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

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/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 (
defaultClient = common.SaneHttpClient()
connectionStringPat = regexp.MustCompile(`Endpoint=(https:\/\/[a-zA-Z0-9-]+\.azconfig\.io);Id=([a-zA-Z0-9+\/=]+);Secret=([a-zA-Z0-9+\/=]+)`)
)

// 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{".azconfig.io"}
}

// 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)

keyMatchesUnique := make(map[string][]string)
for _, keyMatch := range connectionStringPat.FindAllStringSubmatch(dataStr, -1) {
keyMatchesUnique[strings.TrimSpace(keyMatch[0])] = keyMatch // keep all the matched groups for verification
}

for connectionString, connectionInfo := range keyMatchesUnique {
endpoint := connectionInfo[1] // Endpoint
id := connectionInfo[2] // Id
secret := connectionInfo[3] // Secret
s1 := detectors.Result{
DetectorType: detectorspb.DetectorType_AzureAppConfigConnectionString,
Raw: []byte(id),
RawV2: []byte(connectionString),
}

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

isVerified, verificationErr := s.verifyMatch(ctx, client, endpoint, id, secret)
s1.Verified = isVerified

if verificationErr != nil && !strings.Contains(verificationErr.Error(), "no such host") { // ignore no such host errors
s1.SetVerificationError(verificationErr, connectionString)
}
}

results = append(results, s1)
}

return results, nil
}

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

func (s Scanner) Description() string {
return "Azure App Configuration is a managed service that centralizes application settings and feature flags, enabling dynamic updates without redeploying applications. Its connection string, which includes the endpoint URL and an access key, securely connects applications to the configuration store."
}

// generateHMACSignature creates the HMAC-SHA256 signature
func generateHMACSignature(secret, stringToSign string) (string, error) {
decodedSecret, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
return "", fmt.Errorf("failed to decode secret: %w", err)
}

h := hmac.New(sha256.New, decodedSecret)
h.Write([]byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return signature, nil
}

// verifyMatch sends a request to the Azure App Configuration REST API to verify the provided credentials
// https://learn.microsoft.com/en-us/azure/azure-app-configuration/rest-api-authentication-hmac
func (s Scanner) verifyMatch(ctx context.Context, client *http.Client, endpoint, id, secret string) (bool, error) {
apiVersion := "1.0"
requestPath := "/kv"
query := fmt.Sprintf("?api-version=%s", apiVersion)
url := fmt.Sprintf("%s%s%s", endpoint, requestPath, query)

// Prepare request
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return false, fmt.Errorf("failed to create request: %w", err)
}

// Set required headers
host := strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
date := time.Now().UTC().Format(http.TimeFormat)
contentHash := base64.StdEncoding.EncodeToString(sha256.New().Sum(nil)) // SHA256 hash of an empty body

req.Header.Set("Host", host)
req.Header.Set("Date", date)
req.Header.Set("x-ms-content-sha256", contentHash)

// Create the string to sign
stringToSign := fmt.Sprintf("%s\n%s%s\n%s;%s;%s",
http.MethodGet,
requestPath,
query,
date,
host,
contentHash,
)

// Generate the HMAC signature
signature, err := generateHMACSignature(secret, stringToSign)
if err != nil {
return false, fmt.Errorf("failed to generate HMAC signature: %w", err)
}

// Set the Authorization header
authorizationHeader := fmt.Sprintf(
"HMAC-SHA256 Credential=%s&SignedHeaders=date;host;x-ms-content-sha256&Signature=%s",
id,
signature,
)
req.Header.Set("Authorization", authorizationHeader)

// Send the request
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

// Check the response status
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized:
return false, nil
default:
return false, fmt.Errorf("got unexpected status code: %d", resp.StatusCode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//go:build detectors
// +build detectors

package azureappconfigconnectionstring

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 TestAzureAppConfigConnectionString_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)
}
secret := testSecrets.MustGetField("AZURE_APP_CONFIGURATION_CONNECTION_STRING")
inactiveSecret := testSecrets.MustGetField("AZURE_APP_CONFIGURATION_CONNECTION_STRING_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 azureappconfigconnectionstring secret %s within", secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_AzureAppConfigConnectionString,
Verified: true,
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, unverified",
s: Scanner{},
args: args{
ctx: ctx,
data: []byte(fmt.Sprintf("You can find a azureappconfigconnectionstring secret %s but not valid", inactiveSecret)), // the secret would satisfy the regex but not pass validation
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_AzureAppConfigConnectionString,
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("AzureAppConfigConnectionString.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("AzureAppConfigConnectionString.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,80 @@
package azureappconfigconnectionstring

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 = `Endpoint=https://trufflesecurity.azconfig.io;Id=u+De;Secret=80DtxZkndXpM2mV2J1JjX2vL1x4gm1hHn8Y3JeFJ4N0PPLSO5D70JQQJ99BBAC1i4FpQkb5wAAACAAZC26dr`
invalidPattern = `Endpoint=https://trufflesecurity.azconfig.io;Secret=80DtxZkndXpMTmV2J3JjX2vL1x4gm1hHn8Y3KeFV4N0PPLSO5D70JQQJ79BBAC1i4FpRkb5wAAACAAZC26dr`
)

func TestAzureAppConfigConnectionString_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{"Endpoint=https://trufflesecurity.azconfig.io;Id=u+De;Secret=80DtxZkndXpM2mV2J1JjX2vL1x4gm1hHn8Y3JeFJ4N0PPLSO5D70JQQJ99BBAC1i4FpQkb5wAAACAAZC26dr"},
},
{
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
Expand Up @@ -71,6 +71,7 @@ import (
azure_entra_serviceprincipal_v2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_entra/serviceprincipal/v2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_openai"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azure_storage"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azureappconfigconnectionstring"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azurecontainerregistry"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuredevopspersonalaccesstoken"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/azuresearchadminkey"
Expand Down Expand Up @@ -897,6 +898,7 @@ func buildDetectorList() []detectors.Detector {
&azure_entra_serviceprincipal_v1.Scanner{},
&azure_entra_serviceprincipal_v2.Scanner{},
&azure_batch.Scanner{},
&azureappconfigconnectionstring.Scanner{},
&azurecontainerregistry.Scanner{},
&azuredevopspersonalaccesstoken.Scanner{},
// &azurefunctionkey.Scanner{}, // detector is throwing some FPs
Expand Down
Loading
Loading