package docker import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "github.com/go-logr/logr" regexp "github.com/wasilibs/go-re2" "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 } // Ensure the Scanner satisfies the interface at compile time. var _ interface { detectors.Detector detectors.MaxSecretSizeProvider } = (*Scanner)(nil) func (s Scanner) Type() detector_typepb.DetectorType { return detector_typepb.DetectorType_Docker } func (s Scanner) Description() string { return "Docker credentials can be used to pull images from private registries." } // Keywords are used for efficiently pre-filtering chunks. // Use identifiers in the secret preferably, or the provider name. func (s Scanner) Keywords() []string { return []string{`"auths"`, `\"auths\`} } func (s Scanner) MaxSecretSize() int64 { return 4096 } var ( keyPat = regexp.MustCompile(`{(?:\s|\\+[nrt])*\\*"auths\\*"(?:\s|\\+t)*:(?:\s|\\+t)*{(?:\s|\\+[nrt])*\\*"(?i:https?:\/\/)?[a-z0-9\-.:\/]+\\*"(?:\s|\\+t)*:(?:\s|\\+t)*{(?:(?:\s|\\+[nrt])*\\*"(?i:auth|email|username|password)\\*"\s*:\s*\\*".*\\*"\s*,?)+?(?:\s|\\+[nrt])*}(?:\s|\\+[nrt])*}(?:\s|\\+[nrt])*}`) escapedReplacer = strings.NewReplacer( `\n`, "", `\r`, "", `\t`, "", `\\`, ``, `\"`, `"`, ) // Common false-positives used in examples. exampleRegistries = map[string]struct{}{ "https://index.docker.io/v1/": {}, // https://github.com/moby/moby/blob/34679e568a22b4f35ff8460f3b5b7bf7089df818/cliconfig/config_test.go#L259 "registry.hostname.com": {}, // https://github.com/openshift/machine-config-operator/blob/82011335dbdd3d4c869b959d6048a3fba7742e47/pkg/controller/build/helpers_test.go#L47 "registry.example.com:5000": {}, // https://github.com/openshift/cluster-baremetal-operator/blob/f908020b1d46667056f21cf1d79e032c535a41fc/provisioning/baremetal_secrets_test.go#L53 "registry2.example.com:5000": {}, "your.private.registry.example.com": {}, // https://github.com/kubernetes/website/blob/d130f326758988553c42179c087bfeec5bf948a0/content/en/docs/tasks/configure-pod-container/pull-image-private-registry.md?plain=1#L167 } ) // FromData will find and optionally verify Docker secrets in a given set of bytes. func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { dataStr := string(data) logCtx := logContext.AddLogger(ctx) logger := logCtx.Logger().WithName("docker") uniqueMatches := make(map[string]struct{}) for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) { uniqueMatches[match[0]] = struct{}{} } for match := range uniqueMatches { // Remove escaped quotes and literal whitespace characters, if present. // It is common for auth to be escaped, however, the json package cannot unmarshal escaped JSON. match := escapedReplacer.Replace(match) // Unmarshal the config string. // Doing byte->string->byte probably isn't the most efficient. var auths dockerAuths if err := json.NewDecoder(strings.NewReader(match)).Decode(&auths); err != nil { logger.Error(err, "Could not parse Docker auth JSON") return results, err } else if len(auths.Auths) == 0 { continue } for registry, auth := range auths.Auths { // `docker.io` is a special case, Docker is hard-coded to rewrite it as `index.docker.io`. // https://github.com/moby/moby/blob/145a73a36c171b34c196ad780e699b154ddf47b5/registry/config_test.go#L329 if strings.EqualFold(registry, "docker.io") { registry = "index.docker.io" } // Skip known invalid registries. if _, ok := exampleRegistries[registry]; ok { continue } // Skip configs with no credentials. // TODO: Should this be an error? What if it's a logic issue? username, password, b64encoded := parseBasicAuth(logger, auth) if username == "" && password == "" { logger.V(2).Info("Skipping empty credentials", "auth", auth, "username", username, "password", password) continue } r := detectors.Result{ DetectorType: detector_typepb.DetectorType_Docker, Raw: []byte(b64encoded), SecretParts: map[string]string{ "registry": registry, "auth": b64encoded, }, RawV2: []byte(`{"registry":"` + registry + `","auth":"` + b64encoded + `"}`), ExtraData: map[string]string{"Username": username}, } if verify { client := s.client if client == nil { client = common.SaneHttpClient() } isVerified, verificationErr := verifyMatch(logCtx, client, registry, username, b64encoded) r.Verified = isVerified r.SetVerificationError(verificationErr, match) } results = append(results, r) } } return } func verifyMatch(ctx logContext.Context, client *http.Client, registry string, username string, basicAuth string) (bool, error) { // Build the registry URL path. var registryUrl string registry, _ = strings.CutSuffix(registry, "/") if strings.HasPrefix(registry, "http://") || strings.HasPrefix(registry, "https://") { registryUrl = registry + "/v2/" } else { registryUrl = "https://" + registry + "/v2/" } // Build the request. req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryUrl, nil) if err != nil { return false, err } req.Header.Set("Authorization", "Basic "+basicAuth) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") // Send the initial request. res, err := client.Do(req) if err != nil { return false, err } defer func() { _, _ = io.Copy(io.Discard, res.Body) _ = res.Body.Close() }() // Handle the initial response. switch res.StatusCode { case http.StatusOK: body, err := io.ReadAll(res.Body) if err != nil { return false, err } return json.Valid(body), nil case http.StatusUnauthorized: // Some registries do not support basic auth, so we must follow the `Www-Authenticate` header, if present. // https://distribution.github.io/distribution/spec/auth/token/ h := res.Header.Get("Www-Authenticate") if h == "" { return false, nil } if !strings.HasPrefix(h, "Bearer") { return false, fmt.Errorf("unsupported WWW-Authenticate auth scheme: %s", h) } authParams, err := parseAuthenticateHeader(h) if err != nil { return false, fmt.Errorf("failed to parse registry auth header: %w", err) } realm := authParams["realm"] if realm == "" { return false, fmt.Errorf("unexpected empty realm for WWW-Authenticate header: %s", h) } authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, realm, nil) if err != nil { return false, nil } authReq.Header.Set("Authorization", "Basic "+basicAuth) authReq.Header.Set("Accept", "application/json") authReq.Header.Set("Content-Type", "application/json") params := url.Values{} params.Add("account", username) params.Add("service", authParams["service"]) authReq.URL.RawQuery = params.Encode() authRes, err := client.Do(authReq) if err != nil { return false, err } defer func() { _, _ = io.Copy(io.Discard, authRes.Body) _ = authRes.Body.Close() }() switch authRes.StatusCode { case http.StatusOK: return true, nil case http.StatusUnauthorized, http.StatusForbidden: // Auth was rejected. return false, nil default: return false, fmt.Errorf("unexpected HTTP response status %d for '%s'", authRes.StatusCode, authReq.URL.String()) } default: err = fmt.Errorf("unexpected HTTP response status %d for '%s'", res.StatusCode, req.URL.String()) return false, err } } type dockerAuths struct { Auths map[string]dockerAuth `json:"auths"` } type dockerAuth struct { Auth string `json:"auth"` Username string `json:"username"` Password string `json:"password"` Email string `json:"email"` } // parseBasicAuth handles cases where configs can have `username` and `password` but no `auth`, // or vice-versa. func parseBasicAuth(logger logr.Logger, auth dockerAuth) (string, string, string) { var ( username string password string ) if auth.Username != "" && auth.Password != "" { username = auth.Username password = auth.Password } if auth.Auth != "" { data, err := base64.StdEncoding.DecodeString(auth.Auth) if err != nil { goto end } parts := strings.SplitN(string(data), ":", 2) if len(parts) != 2 { logger.V(2).Info("Skipping invalid parts", "length", len(parts), "parts", parts) goto end } if (username != "" && parts[0] != username) || (password != "" && parts[1] != password) { logger.V(2).Info("WARNING: Creds have more than two usernames or passwords") } username = parts[0] password = parts[1] } end: if username == "" && password == "" { return "", "", "" } basicAuth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) if auth.Auth != "" && basicAuth != auth.Auth { logger.Error(fmt.Errorf("base64-encoded auth does not match source"), "failed to parse auths JSON") } return username, password, basicAuth } // This is an ad-hoc implementation and not RFC compliant. // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate func parseAuthenticateHeader(headerValue string) (map[string]string, error) { authParams := make(map[string]string) parts := strings.Split(headerValue, " ") if len(parts) < 2 { return nil, fmt.Errorf("invalid WWW-Authenticate header format") } authParams["scheme"] = parts[0] parts = strings.Split(parts[1], ",") for _, part := range parts { keyVal := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(keyVal) == 2 { key := strings.TrimSpace(keyVal[0]) value := strings.Trim(strings.TrimSpace(keyVal[1]), `"`) authParams[key] = value } } return authParams, nil }