Files
trufflehog/pkg/detectors/azuresastoken/azuresastoken.go
genisis0x f38f8f7dd7 fix(azuresastoken): match SAS tokens regardless of parameter order (#5043)
* fix(azuresastoken): match SAS tokens regardless of parameter order

The keyPat regex required a fixed parameter order
(sp, st, se, [sip], [spr], sv, sr, sig) and literal ':' in the timestamps.
Real SAS tokens vary: Azure Storage Explorer emits the parameters in a
different order (sv first, sp last) and URL-encodes the ':' in st/se as
%3A, so those tokens were silently missed.

Match a contiguous run of query parameters and validate the SAS-specific
parameters (sp, sv, sr, sig, and st/se, plus optional sip) in code instead.
This makes detection order-independent and tolerant of URL-encoded values
while preserving the previous validations (permission set, resource type,
timestamp shape, IP format) so the existing false-positive cases still
return no result.

Adds test cases for the alternate-order / URL-encoded-timestamp token and
for a non-SAS query string.

Closes #4732

* fix(azuresastoken): accept lowercase percent-encoded colons in timestamps

timeValuePat only matched uppercase %3A for the URL-encoded ':' in start
and expiry timestamps, but percent-encoding hex digits are case-insensitive
per RFC 3986, so a token encoded with %3a was silently missed. Accept
%3[Aa] in both separator positions and add a lowercase-hex test case.

* fix(azuresastoken): exclude '=' from the SAS value class

The sasQueryPat value class allowed '=', so a SAS token preceded by a
short lowercase key (e.g. 'sas=sp=r&...', or 'oken=' inside 'token=')
matched from that key: the first value greedily absorbed 'sp=r', and
since FindAllString returns non-overlapping matches the real run
starting at 'sp' was never tried. keyMatchIsSASToken then found no 'sp'
parameter and the token was silently missed.

Exclude '=' from the value class. Azure URL-encodes any '=' inside a
value (signature padding becomes %3D), so no legitimate value is dropped.
Adds a regression test for a token preceded by a lowercase assignment
(fails on the old class with zero results).
2026-07-01 14:34:18 -05:00

238 lines
7.7 KiB
Go

package azuresastoken
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/cache/simple"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
client *http.Client
detectors.DefaultMultiPartCredentialProvider
}
var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil)
var (
defaultClient = common.SaneHttpClient()
// microsoft storage resource naming rules: https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules#microsoftstorage:~:text=format%3A%0AVaultName_KeyName_KeyVersion.-,Microsoft.Storage,-Expand%20table
urlPat = regexp.MustCompile(`https://([a-zA-Z0-9][a-z0-9_-]{1,22}[a-zA-Z0-9])\.blob\.core\.windows\.net/[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])?(?:/[a-zA-Z0-9._-]+)*`)
// A SAS token is a query string of `&`-joined `key=value` pairs. The parameters
// can appear in any order, and values may be URL-encoded (e.g. Azure Storage
// Explorer encodes the `:` in the timestamps as %3A and the `+`, `/`, `=` in the
// signature as %2B, %2F, %3D). Rather than enumerate every permutation in one
// regex, match a contiguous run of query parameters and validate the SAS-specific
// parameters in keyMatchIsSASToken below. This keeps detection order-independent.
// The value class excludes `=` so a SAS token preceded by a short lowercase
// `key=` (e.g. `sas=sp=r&...`) is not absorbed from that preceding key, which
// would bury the real `sp` parameter inside the first value and fail
// validation. Azure URL-encodes any `=` inside a value (the signature padding
// becomes %3D), so excluding the literal does not drop a legitimate value.
sasQueryPat = regexp.MustCompile(`[a-z]{2,4}=[^&=\s"'<>]+(?:&[a-z]{2,4}=[^&=\s"'<>]+)+`)
// Validators for the individual SAS parameters. The `sp` permission set and `sr`
// resource set match the formats the previous, order-locked regex accepted.
spValuePat = regexp.MustCompile(`^[racwdli]+$`)
svValuePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
srValuePat = regexp.MustCompile(`^[bcfso]$`)
// The `:` separators may be URL-encoded; percent-encoding hex digits are
// case-insensitive per RFC 3986, so accept both %3A and %3a.
timeValuePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}(?::|%3[Aa])\d{2}(?::|%3[Aa])\d{2}Z$`)
sigValuePat = regexp.MustCompile(`^[a-zA-Z0-9%+/=]{10,}$`)
sipValuePat = regexp.MustCompile(`^\d{1,3}(?:\.\d{1,3}){3}(?:-\d{1,3}(?:\.\d{1,3}){3})?$`)
invalidStorageAccounts = simple.NewCache[struct{}]()
errNoSuchHost = errors.New("no such host")
)
func (s Scanner) Keywords() []string {
return []string{
"azure",
".blob.core.windows.net",
}
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_AzureSasToken
}
func (s Scanner) Description() string {
return "An Azure Shared Access Signature (SAS) token is a time-limited, permission-based URL query string that grants secure, granular access to Azure Storage resources (e.g., blobs, containers, files) without exposing account keys."
}
// keyMatchIsSASToken reports whether a matched query-parameter run is a Azure
// Storage SAS token. The required parameters may appear in any order; values may
// be URL-encoded. This reproduces the validations the previous order-locked regex
// enforced (permission set, resource type, timestamp shape, signature, optional IP)
// without depending on parameter order.
func keyMatchIsSASToken(query string) bool {
params := make(map[string]string)
for _, pair := range strings.Split(query, "&") {
key, value, ok := strings.Cut(pair, "=")
if !ok {
continue
}
params[key] = value
}
sp, ok := params["sp"]
if !ok || !spValuePat.MatchString(sp) {
return false
}
sv, ok := params["sv"]
if !ok || !svValuePat.MatchString(sv) {
return false
}
sr, ok := params["sr"]
if !ok || !srValuePat.MatchString(sr) {
return false
}
sig, ok := params["sig"]
if !ok || !sigValuePat.MatchString(sig) {
return false
}
// A SAS token carries a start time, an expiry time, or both; any present one
// must be well-formed.
st, hasStart := params["st"]
se, hasExpiry := params["se"]
if !hasStart && !hasExpiry {
return false
}
if hasStart && !timeValuePat.MatchString(st) {
return false
}
if hasExpiry && !timeValuePat.MatchString(se) {
return false
}
// The IP restriction is optional, but must be a valid address or range if set.
if sip, ok := params["sip"]; ok && !sipValuePat.MatchString(sip) {
return false
}
return true
}
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
logger := logContext.AddLogger(ctx).Logger().WithName("azuresas")
dataStr := string(data)
// deduplicate urlMatches
urlMatchesUnique := make(map[string]string)
for _, urlMatch := range urlPat.FindAllStringSubmatch(dataStr, -1) {
urlMatchesUnique[urlMatch[0]] = urlMatch[1]
}
// deduplicate keyMatches
keyMatchesUnique := make(map[string]struct{})
for _, keyMatch := range sasQueryPat.FindAllString(dataStr, -1) {
if keyMatchIsSASToken(keyMatch) {
keyMatchesUnique[keyMatch] = struct{}{}
}
}
// Check results.
UrlLoop:
for url, storageAccount := range urlMatchesUnique {
for key := range keyMatchesUnique {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_AzureSasToken,
Raw: []byte(url),
SecretParts: map[string]string{
"url": url,
"key": key,
},
RawV2: []byte(url + key),
}
if verify {
if invalidStorageAccounts.Exists(storageAccount) {
logger.V(3).Info("Skipping invalid storage account", "storage account", storageAccount)
break
}
client := s.client
if client == nil {
client = defaultClient
}
isVerified, verificationErr := verifyMatch(ctx, client, url, key, true)
s1.Verified = isVerified
if verificationErr != nil {
if errors.Is(verificationErr, errNoSuchHost) {
invalidStorageAccounts.Set(storageAccount, struct{}{})
continue UrlLoop
}
s1.SetVerificationError(verificationErr, key)
}
}
results = append(results, s1)
}
}
return results, nil
}
func (s Scanner) IsFalsePositive(_ detectors.Result) (bool, string) {
return false, ""
}
func verifyMatch(ctx context.Context, client *http.Client, url, key string, retryOn403 bool) (bool, error) {
urlWithToken := url + "?" + key
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlWithToken, nil)
if err != nil {
return false, err
}
res, err := client.Do(req)
if err != nil {
if strings.Contains(err.Error(), "no such host") {
return false, errNoSuchHost
}
return false, err
}
defer func() { _ = res.Body.Close() }()
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return false, err
}
switch res.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusForbidden:
if retryOn403 && strings.Contains(string(bodyBytes), "Signature did not match") {
// need to add additional query parameters for container urls
// https://stackoverflow.com/questions/25038429/azure-shared-access-signature-signature-did-not-match
return verifyMatch(ctx, client, url, key+"&comp=list&restype=container", false)
}
if strings.Contains(string(bodyBytes), "AuthorizationFailure") && strings.Contains(key, "&sip=") {
return false, fmt.Errorf("SAS token is restricted to specific IP addresses")
}
return false, nil
default:
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
}
}