Files
trufflehog/pkg/detectors/jwt/jwt.go
Muneeb Ullah Khan 7f04a89676 [INS-465] Skip unverified JWT Detector results when feature flag is enabled (#5072)
* Skip unverified jwt results when  feature flag is enabled

* update man page
2026-07-02 12:25:24 +05:00

299 lines
8.9 KiB
Go

package jwt
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/lestrrat-go/jwx/v3/jwk"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct{}
// Ensure the Scanner satisfies expected interfaces at compile time.
var _ interface {
detectors.Detector
detectors.MaxSecretSizeProvider
} = (*Scanner)(nil)
var keyPat = regexp.MustCompile(`\b((?:eyJ|ewogIC|ewoid)[A-Za-z0-9_-]{12,}={0,2}\.(?:eyJ|ewo)[A-Za-z0-9_-]{12,}={0,2}\.[A-Za-z0-9_-]{12,})\b`)
// The default max secret size value for this detector must be overridden or JWTs with lots of claims will get missed.
func (s Scanner) MaxSecretSize() int64 {
return 4096
}
// These keywords are derived from prefixes of the base64url-encoded versions of JSON object strings like the following:
//
// `{"typ":"`
// `{"alg":"`
// `{\n "typ":"`
// `{\n "typ":"`
func (s Scanner) Keywords() []string {
return []string{
"ewogIC",
"ewoid",
"eyJ",
}
}
var jwtOptions = []jwt.ParserOption{
jwt.WithValidMethods([]string{
// HMAC-based algorithms
// jwt.SigningMethodHS256.Alg(),
// jwt.SigningMethodHS384.Alg(),
// jwt.SigningMethodHS512.Alg(),
// Public key-based algorithms
jwt.SigningMethodRS256.Alg(),
jwt.SigningMethodRS384.Alg(),
jwt.SigningMethodRS512.Alg(),
jwt.SigningMethodEdDSA.Alg(),
jwt.SigningMethodES256.Alg(),
jwt.SigningMethodES384.Alg(),
jwt.SigningMethodES512.Alg(),
jwt.SigningMethodPS256.Alg(),
jwt.SigningMethodPS384.Alg(),
jwt.SigningMethodPS512.Alg(),
}),
jwt.WithIssuedAt(),
jwt.WithPaddingAllowed(),
jwt.WithLeeway(time.Minute),
}
// FromData will find and optionally verify JWT secrets in a given set of bytes.
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
jwtParser := jwt.NewParser(jwtOptions...)
client := detectors.DetectorHttpClientWithNoLocalAddresses
seenMatches := make(map[string]struct{})
skipUnverified := feature.DropUnverifiedJWTResults.Load()
for _, matchGroups := range keyPat.FindAllStringSubmatch(string(data), -1) {
match := matchGroups[1]
if _, ok := seenMatches[match]; ok {
continue
}
seenMatches[match] = struct{}{}
claims := jwt.MapClaims{}
parsedToken, tokenParts, err := jwtParser.ParseUnverified(match, claims)
if err != nil || len(tokenParts) != 3 {
// skip malformed tokens; no need to do claims validation or signature verification
continue
}
switch parsedToken.Method.Alg() {
case "HS256", "HS384", "HS512":
// The JWT *might* be valid, but we can't in general do signature verification on HMAC-based algorithms.
// We don't have a suitable status to represent this situation in trufflehog.
// (The `unknown` status is intended to indicate that an error occurred due to external environmental conditions, like transient network errors.)
// So instead, to avoid possible false positives, totally skip HMAC-based JWTs; don't even create results for them.
continue
}
// Decode signature
parsedToken.Signature, err = jwtParser.DecodeSegment(tokenParts[2])
if err != nil {
// skip JWTs with malformed signatures
continue
}
issString, _ := claims.GetIssuer()
iatString := ""
iat, err := claims.GetIssuedAt()
if err == nil && iat != nil {
iatString = iat.String()
}
expString := ""
exp, err := claims.GetExpirationTime()
if err == nil && exp != nil {
expString = exp.String()
}
extraData := map[string]string{
"alg": parsedToken.Method.Alg(),
"iss": issString,
"iat": iatString,
"exp": expString,
}
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_JWT,
Raw: []byte(match),
SecretParts: map[string]string{"key": match},
ExtraData: extraData,
}
if verify {
isVerified, verificationErr := verifyJWT(ctx, client, tokenParts, parsedToken)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, match)
}
// Remove unverified results from jwt detector output when the "drop-unverified-jwt-results" feature flag is enabled.
if skipUnverified && verify && !s1.Verified && s1.VerificationError() == nil {
continue
}
results = append(results, s1)
}
return
}
// Parse a string into a URL and check that it is an HTTPS URL.
func parseHttpsUrl(urlString string) (*url.URL, error) {
url, err := url.ParseRequestURI(urlString)
if err != nil {
return nil, err
}
if url.Scheme != "https" {
return nil, fmt.Errorf("only https scheme is supported")
}
return url, nil
}
func performHttpRequest(ctx context.Context, client *http.Client, method string, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request: %w", err)
}
return resp, nil
}
// Wrap an `io.Reader` with a reasonable limit as an additional measure against DoS from a malicious JWKS issuer
func limitReader(reader io.Reader) io.Reader {
return io.LimitReader(reader, 1024*1024)
}
// Attempt to verify a JWT
//
// This cannot be done in general, but in a few special cases we can get definitive answers.
//
// In particular:
//
// - If the JWT uses public key cryptography and the OIDC Discovery protocol, we can fetch the public key and perform signature verification
// - In all cases, we can perform claims validation (e.g., checking expiration time) and sometimes get a definite answer that a JWT is *not* live
func verifyJWT(ctx context.Context, client *http.Client, tokenParts []string, parsedToken *jwt.Token) (bool, error) {
jwtValidator := jwt.NewValidator(jwtOptions...)
if err := jwtValidator.Validate(parsedToken.Claims); err != nil {
// though we have not checked the signature, the token is definitely invalid
return false, nil
}
// Use the OIDC Discovery protocol to fetch the public signing key,
// being careful to avoid possible DoS from a potentially malicious JWKS server.
issuer, err := parsedToken.Claims.GetIssuer()
if err != nil || issuer == "" {
// missing or invalid issuer
return false, nil
}
issuerURL, err := parseHttpsUrl(issuer)
if err != nil {
// unsupported issuer
return false, nil
}
oidcDiscoveryURL := issuerURL.JoinPath(".well-known/openid-configuration")
// Check for a proper key id before making any network requests
kid, ok := parsedToken.Header["kid"].(string)
if !ok {
// invalid key id
return false, nil
}
// Fetch the OIDC discovery document
resp, err := performHttpRequest(ctx, client, "GET", oidcDiscoveryURL.String())
if err != nil {
return false, fmt.Errorf("failed to perform OIDC discovery: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
return false, fmt.Errorf("bad status for OIDC discovery document: %v", resp.Status)
}
// Get the JWKS URL from the OIDC discovery document
var discoveryDoc struct {
JWKSUri string `json:"jwks_uri"`
}
if err := json.NewDecoder(limitReader(resp.Body)).Decode(&discoveryDoc); err != nil {
return false, fmt.Errorf("failed to decode OIDC discovery document: %w", err)
}
jwksURL, err := parseHttpsUrl(discoveryDoc.JWKSUri)
if err != nil {
return false, fmt.Errorf("invalid JWKS URL: %w", err)
}
if jwksURL.Host != issuerURL.Host {
return false, fmt.Errorf("JWKS host does not match issuer host: %q", discoveryDoc.JWKSUri)
}
// Fetch the JWKS
resp, err = performHttpRequest(ctx, client, "GET", discoveryDoc.JWKSUri)
if err != nil {
return false, fmt.Errorf("failed to fetch JWKS: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
return false, fmt.Errorf("bad status for JWKS: %v", resp.Status)
}
// Parse the JWKS and find the first matching key
keySet, err := jwk.ParseReader(limitReader(resp.Body))
if err != nil {
return false, fmt.Errorf("failed to parse JWKS: %w", err)
}
matchingKey, found := keySet.LookupKeyID(kid)
if !found {
// this is a determinate failure indicating rotation
return false, nil
}
// Parse matching key to the "raw" key type needed for signature verification
var rawMatchingKey any
err = jwk.Export(matchingKey, &rawMatchingKey)
if err != nil {
return false, fmt.Errorf("failed to export matching key: %w", err)
}
err = parsedToken.Method.Verify(strings.Join(tokenParts[0:2], "."), parsedToken.Signature, rawMatchingKey)
if err != nil {
// signature invalid
return false, nil
}
// signature valid and claims check out
return true, nil
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_JWT
}
func (s Scanner) Description() string {
return "A JSON Web Token (JWT) is an approach to authentication or authorization that does not depend on server-side data. It may allow access to protected resources."
}