Skip to content
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
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +52,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 {
Expand Down
123 changes: 123 additions & 0 deletions pkg/detectors/microsoftteamswebhook/v2/microsoftteamswebhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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<envId>.<region>.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/<workflowId>/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=<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_\-]+)`)
)
Comment thread
shahzadhaider1 marked this conversation as resolved.

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),
ExtraData: map[string]string{
"version": fmt.Sprintf("%d", s.Version()),
},
Comment thread
shahzadhaider1 marked this conversation as resolved.
}

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 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":"hi from trufflehog"}`)
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."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//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 (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")
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)
}
}
})
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
6 changes: 4 additions & 2 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,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"
Expand Down Expand Up @@ -1355,7 +1356,8 @@ func buildDetectorList() []detectors.Detector {
&metaapi.Scanner{},
&metabase.Scanner{},
&metrilo.Scanner{},
&microsoftteamswebhook.Scanner{},
&microsoftteamswebhookv1.Scanner{},
&microsoftteamswebhookv2.Scanner{},
&mindmeister.Scanner{},
&miro.Scanner{},
&mite.Scanner{},
Expand Down
Loading