-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(detectors): docker auth detector
- Loading branch information
Showing
3 changed files
with
747 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,311 @@ | ||
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/detectorspb" | ||
) | ||
|
||
type Scanner struct { | ||
client *http.Client | ||
} | ||
|
||
// Ensure the Scanner satisfies the interface at compile time. | ||
var _ detectors.Detector = (*Scanner)(nil) | ||
|
||
var ( | ||
defaultClient = common.SaneHttpClient() | ||
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives. | ||
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])*}`) | ||
|
||
// Common false-positives used in examples. | ||
exampleRegistries = map[string]struct{}{ | ||
"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 | ||
} | ||
|
||
escapedReplacer = strings.NewReplacer( | ||
`\n`, "", | ||
`\r`, "", | ||
`\t`, "", | ||
`\"`, `"`, | ||
) | ||
) | ||
|
||
// 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\"`} | ||
} | ||
|
||
// 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.Unmarshal([]byte(match), &auths); err != nil { | ||
logger.Error(err, "Could not parse Docker auth JSON") | ||
return results, err | ||
} else if len(auths.Auths) == 0 { | ||
return results, nil | ||
} | ||
|
||
for registry, auth := range auths.Auths { | ||
registry := registry | ||
// `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 == "" { | ||
//fmt.Printf("Skipping empty credentials: auth=%v, username='%s', password='%s'\n", auth, username, password) | ||
continue | ||
} | ||
|
||
s1 := detectors.Result{ | ||
DetectorType: detectorspb.DetectorType_Docker, | ||
Raw: []byte(b64encoded), | ||
RawV2: []byte(registry + ":" + b64encoded), | ||
ExtraData: map[string]string{"Registry": registry, "Username": username}, | ||
} | ||
|
||
if verify { | ||
client := s.client | ||
if client == nil { | ||
client = defaultClient | ||
} | ||
|
||
isVerified, verificationErr := verifyMatch(logCtx, client, registry, username, b64encoded) | ||
s1.Verified = isVerified | ||
s1.SetVerificationError(verificationErr, match) | ||
} | ||
|
||
// This function will check false positives for common test words, but also it will make sure the key appears 'random' enough to be a real key. | ||
if !s1.Verified && detectors.IsKnownFalsePositive(b64encoded, detectors.DefaultFalsePositives, true) { | ||
continue | ||
} | ||
|
||
results = append(results, s1) | ||
} | ||
} | ||
|
||
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 | ||
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, nil | ||
} | ||
|
||
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. | ||
if res.StatusCode == http.StatusOK { | ||
body, err := io.ReadAll(res.Body) | ||
if err != nil { | ||
return false, err | ||
} | ||
|
||
return json.Valid(body), nil | ||
} else if res.StatusCode == 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() | ||
}() | ||
|
||
if authRes.StatusCode == http.StatusOK { | ||
return true, nil | ||
} else if authRes.StatusCode == http.StatusUnauthorized || authRes.StatusCode == http.StatusForbidden { | ||
// Auth was rejected. | ||
return false, nil | ||
} else { | ||
err = fmt.Errorf("unexpected HTTP response status %d for '%s'", authRes.StatusCode, authReq.URL.String()) | ||
return false, err | ||
} | ||
} else { | ||
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.Info("Skipping invalid parts", "length", len(parts), "parts", parts) | ||
goto end | ||
} | ||
|
||
if (username != "" && parts[0] != username) || (password != "" && parts[1] != password) { | ||
logger.Info("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 | ||
} | ||
|
||
func (s Scanner) Type() detectorspb.DetectorType { | ||
return detectorspb.DetectorType_Docker | ||
} |
Oops, something went wrong.