Skip to content

Commit d4add25

Browse files
authored
feat(detectors): docker auth detector (#2677)
1 parent 8e5ef0f commit d4add25

File tree

4 files changed

+758
-0
lines changed

4 files changed

+758
-0
lines changed
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
package docker
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
13+
"github.com/go-logr/logr"
14+
regexp "github.com/wasilibs/go-re2"
15+
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
17+
logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context"
18+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
19+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
20+
)
21+
22+
type Scanner struct {
23+
client *http.Client
24+
}
25+
26+
// Ensure the Scanner satisfies the interface at compile time.
27+
var _ interface {
28+
detectors.Detector
29+
detectors.MaxSecretSizeProvider
30+
} = (*Scanner)(nil)
31+
32+
func (s Scanner) Type() detectorspb.DetectorType {
33+
return detectorspb.DetectorType_Docker
34+
}
35+
36+
func (s Scanner) Description() string {
37+
return "Docker credentials can be used to pull images from private registries."
38+
}
39+
40+
// Keywords are used for efficiently pre-filtering chunks.
41+
// Use identifiers in the secret preferably, or the provider name.
42+
func (s Scanner) Keywords() []string {
43+
return []string{`"auths"`, `\"auths\`}
44+
}
45+
46+
func (s Scanner) MaxSecretSize() int64 {
47+
return 4096
48+
}
49+
50+
var (
51+
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])*}`)
52+
escapedReplacer = strings.NewReplacer(
53+
`\n`, "",
54+
`\r`, "",
55+
`\t`, "",
56+
`\\`, ``,
57+
`\"`, `"`,
58+
)
59+
60+
// Common false-positives used in examples.
61+
exampleRegistries = map[string]struct{}{
62+
"https://index.docker.io/v1/": {}, // https://github.com/moby/moby/blob/34679e568a22b4f35ff8460f3b5b7bf7089df818/cliconfig/config_test.go#L259
63+
"registry.hostname.com": {}, // https://github.com/openshift/machine-config-operator/blob/82011335dbdd3d4c869b959d6048a3fba7742e47/pkg/controller/build/helpers_test.go#L47
64+
"registry.example.com:5000": {}, // https://github.com/openshift/cluster-baremetal-operator/blob/f908020b1d46667056f21cf1d79e032c535a41fc/provisioning/baremetal_secrets_test.go#L53
65+
"registry2.example.com:5000": {},
66+
"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
67+
}
68+
)
69+
70+
// FromData will find and optionally verify Docker secrets in a given set of bytes.
71+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
72+
dataStr := string(data)
73+
logCtx := logContext.AddLogger(ctx)
74+
logger := logCtx.Logger().WithName("docker")
75+
76+
uniqueMatches := make(map[string]struct{})
77+
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
78+
uniqueMatches[match[0]] = struct{}{}
79+
}
80+
81+
for match := range uniqueMatches {
82+
// Remove escaped quotes and literal whitespace characters, if present.
83+
// It is common for auth to be escaped, however, the json package cannot unmarshal escaped JSON.
84+
match := escapedReplacer.Replace(match)
85+
86+
// Unmarshal the config string.
87+
// Doing byte->string->byte probably isn't the most efficient.
88+
var auths dockerAuths
89+
if err := json.NewDecoder(strings.NewReader(match)).Decode(&auths); err != nil {
90+
logger.Error(err, "Could not parse Docker auth JSON")
91+
return results, err
92+
} else if len(auths.Auths) == 0 {
93+
continue
94+
}
95+
96+
for registry, auth := range auths.Auths {
97+
// `docker.io` is a special case, Docker is hard-coded to rewrite it as `index.docker.io`.
98+
// https://github.com/moby/moby/blob/145a73a36c171b34c196ad780e699b154ddf47b5/registry/config_test.go#L329
99+
if strings.EqualFold(registry, "docker.io") {
100+
registry = "index.docker.io"
101+
}
102+
103+
// Skip known invalid registries.
104+
if _, ok := exampleRegistries[registry]; ok {
105+
continue
106+
}
107+
108+
// Skip configs with no credentials.
109+
// TODO: Should this be an error? What if it's a logic issue?
110+
username, password, b64encoded := parseBasicAuth(logger, auth)
111+
if username == "" && password == "" {
112+
logger.V(2).Info("Skipping empty credentials", "auth", auth, "username", username, "password", password)
113+
continue
114+
}
115+
116+
r := detectors.Result{
117+
DetectorType: detectorspb.DetectorType_Docker,
118+
Raw: []byte(b64encoded),
119+
RawV2: []byte(`{"registry":"` + registry + `","auth":"` + b64encoded + `"}`),
120+
ExtraData: map[string]string{"Username": username},
121+
}
122+
123+
if verify {
124+
client := s.client
125+
if client == nil {
126+
client = common.SaneHttpClient()
127+
}
128+
129+
isVerified, verificationErr := verifyMatch(logCtx, client, registry, username, b64encoded)
130+
r.Verified = isVerified
131+
r.SetVerificationError(verificationErr, match)
132+
}
133+
134+
results = append(results, r)
135+
}
136+
}
137+
return
138+
}
139+
140+
func verifyMatch(ctx logContext.Context, client *http.Client, registry string, username string, basicAuth string) (bool, error) {
141+
// Build the registry URL path.
142+
var registryUrl string
143+
registry, _ = strings.CutSuffix(registry, "/")
144+
if strings.HasPrefix(registry, "http://") || strings.HasPrefix(registry, "https://") {
145+
registryUrl = registry + "/v2/"
146+
} else {
147+
registryUrl = "https://" + registry + "/v2/"
148+
}
149+
150+
// Build the request.
151+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryUrl, nil)
152+
if err != nil {
153+
return false, nil
154+
}
155+
156+
req.Header.Set("Authorization", "Basic "+basicAuth)
157+
req.Header.Set("Accept", "application/json")
158+
req.Header.Set("Content-Type", "application/json")
159+
160+
// Send the initial request.
161+
res, err := client.Do(req)
162+
if err != nil {
163+
return false, err
164+
}
165+
defer func() {
166+
_, _ = io.Copy(io.Discard, res.Body)
167+
_ = res.Body.Close()
168+
}()
169+
170+
// Handle the initial response.
171+
switch res.StatusCode {
172+
case http.StatusOK:
173+
body, err := io.ReadAll(res.Body)
174+
if err != nil {
175+
return false, err
176+
}
177+
178+
return json.Valid(body), nil
179+
case http.StatusUnauthorized:
180+
// Some registries do not support basic auth, so we must follow the `Www-Authenticate` header, if present.
181+
// https://distribution.github.io/distribution/spec/auth/token/
182+
h := res.Header.Get("Www-Authenticate")
183+
if h == "" {
184+
return false, nil
185+
}
186+
187+
if !strings.HasPrefix(h, "Bearer") {
188+
return false, fmt.Errorf("unsupported WWW-Authenticate auth scheme: %s", h)
189+
}
190+
191+
authParams, err := parseAuthenticateHeader(h)
192+
if err != nil {
193+
return false, fmt.Errorf("failed to parse registry auth header: %w", err)
194+
}
195+
realm := authParams["realm"]
196+
if realm == "" {
197+
return false, fmt.Errorf("unexpected empty realm for WWW-Authenticate header: %s", h)
198+
}
199+
200+
authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, realm, nil)
201+
if err != nil {
202+
return false, nil
203+
}
204+
205+
authReq.Header.Set("Authorization", "Basic "+basicAuth)
206+
authReq.Header.Set("Accept", "application/json")
207+
authReq.Header.Set("Content-Type", "application/json")
208+
209+
params := url.Values{}
210+
params.Add("account", username)
211+
params.Add("service", authParams["service"])
212+
authReq.URL.RawQuery = params.Encode()
213+
214+
authRes, err := client.Do(authReq)
215+
if err != nil {
216+
return false, err
217+
}
218+
defer func() {
219+
_, _ = io.Copy(io.Discard, authRes.Body)
220+
_ = authRes.Body.Close()
221+
}()
222+
223+
switch authRes.StatusCode {
224+
case http.StatusOK:
225+
return true, nil
226+
case http.StatusUnauthorized, http.StatusForbidden:
227+
// Auth was rejected.
228+
return false, nil
229+
default:
230+
return false, fmt.Errorf("unexpected HTTP response status %d for '%s'", authRes.StatusCode, authReq.URL.String())
231+
}
232+
default:
233+
err = fmt.Errorf("unexpected HTTP response status %d for '%s'", res.StatusCode, req.URL.String())
234+
return false, err
235+
}
236+
}
237+
238+
type dockerAuths struct {
239+
Auths map[string]dockerAuth `json:"auths"`
240+
}
241+
242+
type dockerAuth struct {
243+
Auth string `json:"auth"`
244+
Username string `json:"username"`
245+
Password string `json:"password"`
246+
Email string `json:"email"`
247+
}
248+
249+
// parseBasicAuth handles cases where configs can have `username` and `password` but no `auth`,
250+
// or vice-versa.
251+
func parseBasicAuth(logger logr.Logger, auth dockerAuth) (string, string, string) {
252+
var (
253+
username string
254+
password string
255+
)
256+
257+
if auth.Username != "" && auth.Password != "" {
258+
username = auth.Username
259+
password = auth.Password
260+
}
261+
262+
if auth.Auth != "" {
263+
data, err := base64.StdEncoding.DecodeString(auth.Auth)
264+
if err != nil {
265+
goto end
266+
}
267+
268+
parts := strings.SplitN(string(data), ":", 2)
269+
if len(parts) != 2 {
270+
logger.V(2).Info("Skipping invalid parts", "length", len(parts), "parts", parts)
271+
goto end
272+
}
273+
274+
if (username != "" && parts[0] != username) || (password != "" && parts[1] != password) {
275+
logger.V(2).Info("WARNING: Creds have more than two usernames or passwords")
276+
}
277+
278+
username = parts[0]
279+
password = parts[1]
280+
}
281+
282+
end:
283+
if username == "" && password == "" {
284+
return "", "", ""
285+
}
286+
287+
basicAuth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
288+
if auth.Auth != "" && basicAuth != auth.Auth {
289+
logger.Error(fmt.Errorf("base64-encoded auth does not match source"), "failed to parse auths JSON")
290+
}
291+
return username, password, basicAuth
292+
}
293+
294+
// This is an ad-hoc implementation and not RFC compliant.
295+
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate
296+
func parseAuthenticateHeader(headerValue string) (map[string]string, error) {
297+
authParams := make(map[string]string)
298+
299+
parts := strings.Split(headerValue, " ")
300+
if len(parts) < 2 {
301+
return nil, fmt.Errorf("invalid WWW-Authenticate header format")
302+
}
303+
authParams["scheme"] = parts[0]
304+
305+
parts = strings.Split(parts[1], ",")
306+
for _, part := range parts {
307+
keyVal := strings.SplitN(strings.TrimSpace(part), "=", 2)
308+
if len(keyVal) == 2 {
309+
key := strings.TrimSpace(keyVal[0])
310+
value := strings.Trim(strings.TrimSpace(keyVal[1]), `"`)
311+
authParams[key] = value
312+
}
313+
}
314+
315+
return authParams, nil
316+
}

0 commit comments

Comments
 (0)