Skip to content

Commit a204484

Browse files
authored
kube config and docker credentials probes (#49)
1 parent 59a0ef0 commit a204484

7 files changed

Lines changed: 931 additions & 0 deletions

File tree

cmd/bagel/scan.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,5 +187,15 @@ func initializeProbes(cfg *models.Config) []probe.Probe {
187187
probes = append(probes, probe.NewPyPIProbe(cfg.Probes.PyPI, registry))
188188
}
189189

190+
// Kubernetes probe — credential extraction from kubeconfig
191+
if cfg.Probes.Kube.Enabled {
192+
probes = append(probes, probe.NewKubeProbe(cfg.Probes.Kube, registry))
193+
}
194+
195+
// Docker/Podman probe — inline registry credentials
196+
if cfg.Probes.Docker.Enabled {
197+
probes = append(probes, probe.NewDockerProbe(cfg.Probes.Docker, registry))
198+
}
199+
190200
return probes
191201
}

pkg/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ func setDefaults(v *viper.Viper) {
100100
v.SetDefault("probes.ai_chats.enabled", true)
101101
v.SetDefault("probes.wireguard.enabled", true)
102102
v.SetDefault("probes.pypi.enabled", true)
103+
v.SetDefault("probes.kube.enabled", true)
104+
v.SetDefault("probes.docker.enabled", true)
103105
v.SetDefault("output.include_file_hashes", false)
104106
v.SetDefault("output.include_file_content", false)
105107

@@ -210,6 +212,9 @@ func setDefaults(v *viper.Viper) {
210212
// Docker
211213
{"name": "docker_config", "patterns": []string{".docker/config.json"}, "type": "glob"},
212214

215+
// Podman / containers — same schema as docker config.json, different path.
216+
{"name": "podman_config", "patterns": []string{".config/containers/auth.json"}, "type": "glob"},
217+
213218
// Kubernetes
214219
{"name": "kubeconfig", "patterns": []string{".kube/config"}, "type": "glob"},
215220

pkg/models/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ type ProbeConfig struct {
4444
AIChats ProbeSettings `yaml:"ai_chats" mapstructure:"ai_chats"`
4545
WireGuard ProbeSettings `yaml:"wireguard" mapstructure:"wireguard"`
4646
PyPI ProbeSettings `yaml:"pypi" mapstructure:"pypi"`
47+
Kube ProbeSettings `yaml:"kube" mapstructure:"kube"`
48+
Docker ProbeSettings `yaml:"docker" mapstructure:"docker"`
4749
}
4850

4951
// ProbeSettings contains settings for a specific probe

pkg/probe/docker.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Copyright (C) 2026 boostsecurity.io
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package probe
5+
6+
import (
7+
"context"
8+
"encoding/base64"
9+
"encoding/json"
10+
"fmt"
11+
"os"
12+
"strings"
13+
14+
"github.com/boostsecurityio/bagel/pkg/detector"
15+
"github.com/boostsecurityio/bagel/pkg/fileindex"
16+
"github.com/boostsecurityio/bagel/pkg/models"
17+
"github.com/rs/zerolog/log"
18+
)
19+
20+
// DockerProbe extracts inline registry credentials from container
21+
// runtime config files (Docker, Podman). The high-value secret is the
22+
// `auths.<host>.auth` field: base64(user:password), stored unencrypted.
23+
// When a credential helper is configured the field stays empty, which
24+
// is the safer pattern — this probe surfaces the cases where it isn't.
25+
//
26+
// Posture findings (credsStore inventory, credHelpers mapping) are
27+
// intentionally out of scope; they're not credentials.
28+
type DockerProbe struct {
29+
enabled bool
30+
config models.ProbeSettings
31+
detectorRegistry *detector.Registry
32+
fileIndex *fileindex.FileIndex
33+
}
34+
35+
// NewDockerProbe creates a container-config credential-extraction probe.
36+
func NewDockerProbe(config models.ProbeSettings, registry *detector.Registry) *DockerProbe {
37+
return &DockerProbe{
38+
enabled: config.Enabled,
39+
config: config,
40+
detectorRegistry: registry,
41+
}
42+
}
43+
44+
// Name returns the probe name.
45+
func (p *DockerProbe) Name() string { return "docker" }
46+
47+
// IsEnabled returns whether the probe is enabled.
48+
func (p *DockerProbe) IsEnabled() bool { return p.enabled }
49+
50+
// SetFingerprintSalt sets the fingerprint salt on the detector registry.
51+
func (p *DockerProbe) SetFingerprintSalt(salt string) {
52+
p.detectorRegistry.SetFingerprintSalt(salt)
53+
}
54+
55+
// SetFileIndex sets the file index for this probe.
56+
func (p *DockerProbe) SetFileIndex(index *fileindex.FileIndex) {
57+
p.fileIndex = index
58+
}
59+
60+
// Execute scans every indexed docker/podman config for inline auths.
61+
func (p *DockerProbe) Execute(ctx context.Context) ([]models.Finding, error) {
62+
if p.fileIndex == nil {
63+
log.Ctx(ctx).Warn().
64+
Str("probe", p.Name()).
65+
Msg("File index not available, skipping docker probe")
66+
return nil, nil
67+
}
68+
69+
var findings []models.Finding
70+
for _, src := range []struct {
71+
patternName string
72+
runtime string
73+
}{
74+
{"docker_config", "docker"},
75+
{"podman_config", "podman"},
76+
} {
77+
for _, path := range p.fileIndex.Get(src.patternName) {
78+
findings = append(findings, p.processConfig(ctx, path, src.runtime)...)
79+
}
80+
}
81+
return findings, nil
82+
}
83+
84+
// dockerConfigDoc is the minimum subset of docker config.json + podman
85+
// auth.json we need. Other fields (HttpHeaders, proxies, plugins, etc.)
86+
// don't carry stored credentials.
87+
type dockerConfigDoc struct {
88+
Auths map[string]struct {
89+
Auth string `json:"auth"`
90+
Username string `json:"username"`
91+
// IdentityToken is used by some registries (Azure ACR, ECR) in
92+
// lieu of basic auth; treat it as a credential too.
93+
IdentityToken string `json:"identitytoken"`
94+
} `json:"auths"`
95+
}
96+
97+
// processConfig parses one container config file and emits a finding
98+
// per host that has an inline credential. The runtime arg ("docker" /
99+
// "podman") goes into metadata so users can tell which CLI owns the
100+
// file when the two configs are both present.
101+
func (p *DockerProbe) processConfig(ctx context.Context, path, runtimeName string) []models.Finding {
102+
content, err := os.ReadFile(path)
103+
if err != nil {
104+
log.Ctx(ctx).Debug().
105+
Err(err).
106+
Str("file", path).
107+
Msg("Cannot read docker config")
108+
return nil
109+
}
110+
111+
var doc dockerConfigDoc
112+
if err := json.Unmarshal(content, &doc); err != nil {
113+
log.Ctx(ctx).Debug().
114+
Err(err).
115+
Str("file", path).
116+
Msg("Cannot parse docker config JSON")
117+
return nil
118+
}
119+
120+
var findings []models.Finding
121+
for host, entry := range doc.Auths {
122+
if entry.Auth != "" {
123+
findings = append(findings, p.findingFromAuth(ctx, path, runtimeName, host, entry.Auth)...)
124+
}
125+
if entry.IdentityToken != "" {
126+
findings = append(findings, p.findingFromIdentityToken(path, runtimeName, host, entry.IdentityToken)...)
127+
}
128+
}
129+
return findings
130+
}
131+
132+
// findingFromAuth emits the inline-basic-auth finding plus any
133+
// downstream classification the registry adds. The decoded password
134+
// flows through the registry so a GitHub PAT, npm token, or JWT stored
135+
// as the registry password gets surfaced with its specific type.
136+
func (p *DockerProbe) findingFromAuth(
137+
ctx context.Context,
138+
path string,
139+
runtimeName string,
140+
host string,
141+
encoded string,
142+
) []models.Finding {
143+
username, password, ok := decodeBasicAuth(encoded)
144+
if !ok {
145+
log.Ctx(ctx).Debug().
146+
Str("file", path).
147+
Str("host", host).
148+
Msg("Skipping malformed docker auth entry")
149+
return nil
150+
}
151+
152+
// Bracket-and-quote notation so common registry keys
153+
// (https://index.docker.io/v1/) — which contain dots, colons, and
154+
// slashes — map back to auths[<key>].auth unambiguously.
155+
source := fmt.Sprintf("file:%s#auths[%q].auth", path, host)
156+
primary := models.Finding{
157+
ID: "docker-registry-inline-auth",
158+
Type: models.FindingTypeSecret,
159+
Fingerprint: models.FingerprintFromFields("docker-registry-inline-auth", path, host),
160+
Probe: p.Name(),
161+
Severity: "critical",
162+
Title: "Container Registry Credential Stored Inline",
163+
Description: "An inline base64(user:password) credential is stored in the container config. " +
164+
"Configure a credential helper (credsStore / credHelpers) so credentials live in the OS keychain " +
165+
"instead of on disk in cleartext.",
166+
Message: fmt.Sprintf("Inline registry credential for %s in %s", host, path),
167+
Path: source,
168+
Metadata: map[string]interface{}{
169+
"runtime": runtimeName,
170+
"registry_host": host,
171+
"username": username,
172+
"has_password": password != "",
173+
"username_present": username != "",
174+
},
175+
}
176+
findings := make([]models.Finding, 0, 4)
177+
findings = append(findings, primary)
178+
179+
// Run the decoded password through the registry. If it matches a
180+
// known token shape (GitHub PAT, JWT, npm token, etc.) those
181+
// findings get attached too — useful when users reuse a real PAT as
182+
// the registry password.
183+
if password != "" {
184+
detCtx := models.NewDetectionContext(models.NewDetectionContextInput{
185+
Source: source,
186+
ProbeName: p.Name(),
187+
})
188+
for _, f := range p.detectorRegistry.DetectAll(password, detCtx) {
189+
if f.Metadata == nil {
190+
f.Metadata = make(map[string]interface{})
191+
}
192+
f.Metadata["runtime"] = runtimeName
193+
f.Metadata["registry_host"] = host
194+
findings = append(findings, f)
195+
}
196+
}
197+
return findings
198+
}
199+
200+
// findingFromIdentityToken reports the ACR/ECR-style identitytoken
201+
// field. We don't decode it (no fixed format), just surface its
202+
// presence and feed it through the registry — JWT enrichment will
203+
// classify it when applicable.
204+
func (p *DockerProbe) findingFromIdentityToken(
205+
path string,
206+
runtimeName string,
207+
host string,
208+
token string,
209+
) []models.Finding {
210+
source := fmt.Sprintf("file:%s#auths[%q].identitytoken", path, host)
211+
primary := models.Finding{
212+
ID: "docker-registry-inline-identity-token",
213+
Type: models.FindingTypeSecret,
214+
Fingerprint: models.FingerprintFromFields("docker-registry-inline-identity-token", path, host),
215+
Probe: p.Name(),
216+
Severity: "critical",
217+
Title: "Container Registry Identity Token Stored Inline",
218+
Description: "A registry identity token is stored in the container config. Identity tokens are " +
219+
"often long-lived OAuth refresh tokens; rotate the token and configure a credential helper.",
220+
Message: fmt.Sprintf("Inline registry identity token for %s in %s", host, path),
221+
Path: source,
222+
Metadata: map[string]interface{}{
223+
"runtime": runtimeName,
224+
"registry_host": host,
225+
},
226+
}
227+
findings := make([]models.Finding, 0, 4)
228+
findings = append(findings, primary)
229+
230+
detCtx := models.NewDetectionContext(models.NewDetectionContextInput{
231+
Source: source,
232+
ProbeName: p.Name(),
233+
})
234+
for _, f := range p.detectorRegistry.DetectAll(token, detCtx) {
235+
if f.Metadata == nil {
236+
f.Metadata = make(map[string]interface{})
237+
}
238+
f.Metadata["runtime"] = runtimeName
239+
f.Metadata["registry_host"] = host
240+
findings = append(findings, f)
241+
}
242+
return findings
243+
}
244+
245+
// decodeBasicAuth splits a base64(user:password) blob. Trailing
246+
// newlines/whitespace can sneak in when humans hand-edit config.json,
247+
// so trim before decoding. Both StdEncoding and RawStdEncoding are
248+
// tried because some emitters omit padding.
249+
func decodeBasicAuth(encoded string) (user, password string, ok bool) {
250+
encoded = strings.TrimSpace(encoded)
251+
if encoded == "" {
252+
return "", "", false
253+
}
254+
raw, err := base64.StdEncoding.DecodeString(encoded)
255+
if err != nil {
256+
raw, err = base64.RawStdEncoding.DecodeString(encoded)
257+
if err != nil {
258+
return "", "", false
259+
}
260+
}
261+
user, password, ok = strings.Cut(string(raw), ":")
262+
if !ok {
263+
return "", "", false
264+
}
265+
return user, password, true
266+
}

0 commit comments

Comments
 (0)