* Skip unverified jwt results when feature flag is enabled * update man page
299 lines
8.9 KiB
Go
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."
|
|
}
|