diff --git a/main.go b/main.go index 64213059e..0681007da 100644 --- a/main.go +++ b/main.go @@ -570,6 +570,7 @@ func run(state overseer.State, logSync func() error) { feature.NewRelicUserKeyDetectorEnabled.Store(true) feature.NewRelicInsightsQueryKeyDetectorEnabled.Store(true) feature.NewRelicMobileAppTokenDetectorEnabled.Store(true) + feature.MSTeamsWebhookV2DetectorEnabled.Store(true) conf := &config.Config{} if *configFilename != "" { diff --git a/pkg/detectors/microsoftteamswebhook/microsoftteamswebhook.go b/pkg/detectors/microsoftteamswebhook/v1/microsoftteamswebhook.go similarity index 95% rename from pkg/detectors/microsoftteamswebhook/microsoftteamswebhook.go rename to pkg/detectors/microsoftteamswebhook/v1/microsoftteamswebhook.go index 823e6cd93..c1b62a949 100644 --- a/pkg/detectors/microsoftteamswebhook/microsoftteamswebhook.go +++ b/pkg/detectors/microsoftteamswebhook/v1/microsoftteamswebhook.go @@ -20,6 +20,9 @@ type Scanner struct { // Ensure the Scanner satisfies the interface at compile time. var _ detectors.Detector = (*Scanner)(nil) +var _ detectors.Versioner = (*Scanner)(nil) + +func (s Scanner) Version() int { return 1 } var ( defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses @@ -50,6 +53,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result } s1.ExtraData = map[string]string{ "rotation_guide": "https://howtorotate.com/docs/tutorials/microsoftteams/", + "version": fmt.Sprintf("%d", s.Version()), } if verify { diff --git a/pkg/detectors/microsoftteamswebhook/microsoftteamswebhook_integration_test.go b/pkg/detectors/microsoftteamswebhook/v1/microsoftteamswebhook_integration_test.go similarity index 100% rename from pkg/detectors/microsoftteamswebhook/microsoftteamswebhook_integration_test.go rename to pkg/detectors/microsoftteamswebhook/v1/microsoftteamswebhook_integration_test.go diff --git a/pkg/detectors/microsoftteamswebhook/microsoftteamswebhook_test.go b/pkg/detectors/microsoftteamswebhook/v1/microsoftteamswebhook_test.go similarity index 100% rename from pkg/detectors/microsoftteamswebhook/microsoftteamswebhook_test.go rename to pkg/detectors/microsoftteamswebhook/v1/microsoftteamswebhook_test.go diff --git a/pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook.go b/pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook.go new file mode 100644 index 000000000..679c7841c --- /dev/null +++ b/pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook.go @@ -0,0 +1,124 @@ +package microsoftteamswebhook + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + regexp "github.com/wasilibs/go-re2" + + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb" +) + +type Scanner struct { + client *http.Client +} + +var _ detectors.Detector = (*Scanner)(nil) +var _ detectors.Versioner = (*Scanner)(nil) +var _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil) + +func (s Scanner) Version() int { return 2 } + +var ( + defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses + + // urlPat matches the base Power Automate webhook URL plus its query string. + // The path is matched strictly; the query string is matched loosely so that + // parameter ordering changes in the future do not break detection. + // Example: https://default..environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows//triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig= + urlPat = regexp.MustCompile(`https://[a-z0-9]+\.\d+\.environment\.api\.powerplatform\.com(?::\d+)?/powerautomate/automations/direct/workflows/[a-f0-9]{32}/triggers/manual/paths/invoke\?[^\s"'<>]+`) + + // sigPat extracts the sig parameter value from anywhere in the query string. + sigPat = regexp.MustCompile(`[?&]sig=([A-Za-z0-9_\-]+)`) +) + +func (s Scanner) Keywords() []string { + return []string{"environment.api.powerplatform.com"} +} + +func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { + dataStr := string(data) + + uniqueMatches := make(map[string]struct{}) + for _, urlMatch := range urlPat.FindAllString(dataStr, -1) { + // sig is the signing key that authenticates the request; without it the URL is not a valid credential. + if sigPat.MatchString(urlMatch) { + uniqueMatches[strings.TrimSpace(urlMatch)] = struct{}{} + } + } + + for secret := range uniqueMatches { + r := detectors.Result{ + DetectorType: detector_typepb.DetectorType_MicrosoftTeamsWebhook, + Raw: []byte(secret), + SecretParts: map[string]string{"key": secret}, + ExtraData: map[string]string{ + "version": fmt.Sprintf("%d", s.Version()), + }, + } + + if verify { + client := s.client + if client == nil { + client = defaultClient + } + isVerified, verificationErr := verifyWebhook(ctx, client, secret) + r.Verified = isVerified + r.SetVerificationError(verificationErr, secret) + } + + results = append(results, r) + } + return results, nil +} + +// verifyWebhook sends a POST request to the webhook URL to verify it is active. +// A 202 response indicates the credential is valid; 400 means the webhook is disabled +// or deleted; 401 means unauthorized. +// The payload sends an empty text and intentionally omits the "type" field so the Power Automate flow accepts +// the request (returning 202) but does not deliver any message to the Teams channel. +func verifyWebhook(ctx context.Context, client *http.Client, webhookURL string) (bool, error) { + payload := strings.NewReader(`{"text":""}`) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, payload) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + res, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("failed to make request: %w", err) + } + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + switch res.StatusCode { + case http.StatusAccepted: + return true, nil + case http.StatusUnauthorized, http.StatusBadRequest: + return false, nil + default: + return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) + } +} + +// IsFalsePositive implements detectors.CustomFalsePositiveChecker. +// The raw value is a full webhook URL, not a short token, so wordlist-based +// false positive detection is not applicable. +func (s Scanner) IsFalsePositive(_ detectors.Result) (bool, string) { + return false, "" +} + +func (s Scanner) Type() detector_typepb.DetectorType { + return detector_typepb.DetectorType_MicrosoftTeamsWebhook +} + +func (s Scanner) Description() string { + return "Microsoft Teams Webhooks (Power Automate) allow external services to communicate with Teams channels by sending messages to a unique Power Automate workflow URL." +} diff --git a/pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook_integration_test.go b/pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook_integration_test.go new file mode 100644 index 000000000..dc92a080a --- /dev/null +++ b/pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook_integration_test.go @@ -0,0 +1,121 @@ +//go:build detectors +// +build detectors + +package microsoftteamswebhook + +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/detector_typepb" +) + +func TestScanner_FromData(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + secret := testSecrets.MustGetField("MICROSOFT_TEAMS_WEBHOOK_V2") + inactiveSecret := testSecrets.MustGetField("MICROSOFT_TEAMS_WEBHOOK_V2_INACTIVE") + + tests := []struct { + name string + s Scanner + data []byte + verify bool + want []detectors.Result + wantErr bool + wantVerificationErr bool + }{ + { + name: "found, verified", + s: Scanner{}, + data: []byte(fmt.Sprintf("teams webhook %s", secret)), + verify: true, + want: []detectors.Result{ + {DetectorType: detector_typepb.DetectorType_MicrosoftTeamsWebhook, Verified: true}, + }, + }, + { + name: "found, unverified (inactive secret)", + s: Scanner{}, + data: []byte(fmt.Sprintf("teams webhook %s", inactiveSecret)), + verify: true, + want: []detectors.Result{ + {DetectorType: detector_typepb.DetectorType_MicrosoftTeamsWebhook, Verified: false}, + }, + }, + { + name: "found, verification error (unexpected response)", + s: Scanner{client: common.ConstantResponseHttpClient(500, "")}, + data: []byte(fmt.Sprintf("teams webhook %s", secret)), + verify: true, + want: []detectors.Result{{DetectorType: detector_typepb.DetectorType_MicrosoftTeamsWebhook, Verified: false}}, + wantVerificationErr: true, + }, + { + name: "found, verification error (timeout)", + s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)}, + data: []byte(fmt.Sprintf("teams webhook %s", secret)), + verify: true, + want: []detectors.Result{{DetectorType: detector_typepb.DetectorType_MicrosoftTeamsWebhook, Verified: false}}, + wantVerificationErr: true, + }, + { + name: "not found", + s: Scanner{}, + data: []byte("no secret here"), + verify: true, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.s.FromData(context.Background(), tt.verify, tt.data) + if (err != nil) != tt.wantErr { + t.Fatalf("FromData() error = %v, wantErr %v", err, tt.wantErr) + } + for i := range got { + if len(got[i].Raw) == 0 { + t.Fatalf("no raw secret present: %+v", got[i]) + } + if len(got[i].SecretParts) == 0 { + t.Fatalf("no secret parts present: %+v", got[i]) + } + if (got[i].VerificationError() != nil) != tt.wantVerificationErr { + t.Errorf("verificationError = %v, wantVerificationErr %v", + got[i].VerificationError(), tt.wantVerificationErr) + } + } + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "ExtraData", "verificationError", "primarySecret", "SecretParts", "chunkOffset", "chunkOffsetSet") + if diff := cmp.Diff(tt.want, got, ignoreOpts); diff != "" { + t.Errorf("FromData() diff (-want +got):\n%s", diff) + } + }) + } +} + +func BenchmarkFromData(b *testing.B) { + ctx := context.Background() + s := Scanner{} + for name, data := range detectors.MustGetBenchmarkData() { + b.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/microsoftteamswebhook/v2/microsoftteamswebhook_test.go b/pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook_test.go new file mode 100644 index 000000000..6ccb5d506 --- /dev/null +++ b/pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook_test.go @@ -0,0 +1,79 @@ +package microsoftteamswebhook + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" +) + +var ( + validPattern = "https://defaultabc123def456abc123def456ab.62.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/67b9621a4a744d4abc90035cb396b361/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=r2h9kxq06-gWOJ7QiEHNTxntTw11k2uJA3EZr0SIcIQ" + validPatternReordered = "https://defaultabc123def456abc123def456ab.62.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/67b9621a4a744d4abc90035cb396b361/triggers/manual/paths/invoke?sig=r2h9kxq06-gWOJ7QiEHNTxntTw11k2uJA3EZr0SIcIQ&api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0" + invalidPattern = "https://defaultabc123.webhook.office.com/webhookb2/not-a-v2-url" + noSigPattern = "https://defaultabc123def456abc123def456ab.62.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/67b9621a4a744d4abc90035cb396b361/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0" +) + +func TestScanner_Pattern(t *testing.T) { + d := Scanner{} + ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) + + tests := []struct { + name string + input string + want []string + }{ + { + name: "valid pattern", + input: fmt.Sprintf("teams webhook url = '%s'", validPattern), + want: []string{validPattern}, + }, + { + name: "valid pattern - sig first", + input: fmt.Sprintf("teams webhook url = '%s'", validPatternReordered), + want: []string{validPatternReordered}, + }, + { + name: "invalid pattern - wrong domain", + input: fmt.Sprintf("webhook = '%s'", invalidPattern), + want: []string{}, + }, + { + name: "invalid pattern - missing sig", + input: fmt.Sprintf("webhook = '%s'", noSigPattern), + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched := ahoCorasickCore.FindDetectorMatches([]byte(tt.input)) + if len(tt.want) > 0 && len(matched) == 0 { + t.Errorf("keywords not matched") + return + } + results, err := d.FromData(context.Background(), false, []byte(tt.input)) + if err != nil { + t.Fatal(err) + } + actual := make(map[string]struct{}) + 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{}) + for _, v := range tt.want { + expected[v] = struct{}{} + } + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("(-want +got)\n%s", diff) + } + }) + } +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index fc9ce0e22..7a4913d72 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -488,7 +488,8 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/metaapi" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/metabase" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/metrilo" - "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/microsoftteamswebhook" + microsoftteamswebhookv1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/microsoftteamswebhook/v1" + microsoftteamswebhookv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/microsoftteamswebhook/v2" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/mindmeister" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/miro" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/mite" @@ -1404,7 +1405,8 @@ func buildDetectorList() []detectors.Detector { &metaapi.Scanner{}, &metabase.Scanner{}, &metrilo.Scanner{}, - µsoftteamswebhook.Scanner{}, + µsoftteamswebhookv1.Scanner{}, + µsoftteamswebhookv2.Scanner{}, &mindmeister.Scanner{}, &miro.Scanner{}, &mite.Scanner{}, @@ -1884,6 +1886,8 @@ func buildDetectorList() []detectors.Detector { return !feature.NewRelicInsightsQueryKeyDetectorEnabled.Load() case *newrelicmobileapptoken.Scanner: return !feature.NewRelicMobileAppTokenDetectorEnabled.Load() + case *microsoftteamswebhookv2.Scanner: + return !feature.MSTeamsWebhookV2DetectorEnabled.Load() default: return false } diff --git a/pkg/feature/feature.go b/pkg/feature/feature.go index d77a9d33f..bf460efb1 100644 --- a/pkg/feature/feature.go +++ b/pkg/feature/feature.go @@ -47,6 +47,7 @@ var ( NewRelicUserKeyDetectorEnabled atomic.Bool NewRelicInsightsQueryKeyDetectorEnabled atomic.Bool NewRelicMobileAppTokenDetectorEnabled atomic.Bool + MSTeamsWebhookV2DetectorEnabled atomic.Bool ) type AtomicString struct {