diff --git a/.gitignore b/.gitignore index 3299f24..6097707 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ go.work.sum /bagel dist/ + +# mise-en-place +mise.toml diff --git a/CLA_SIGNATURES.md b/CLA_SIGNATURES.md index 666a139..068e7ca 100644 --- a/CLA_SIGNATURES.md +++ b/CLA_SIGNATURES.md @@ -2,4 +2,5 @@ SUSTAPLE117 - Alexis-Maurer Fortin fproulx-boostsecurity - François Proulx Talgarr - Sebastien Graveline julien-boost - Julien Champoux -GuillaumeRoss - Guillaume Ross \ No newline at end of file +GuillaumeRoss - Guillaume Ross +jksolbakken - Jan-Kåre Solbakken \ No newline at end of file diff --git a/README.md b/README.md index 1bceceb..cb0f117 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Bagel is a cross‑platform CLI that inspects developer workstations (macOS, Linux, Windows) and produces a structured report of: -* **Dev tool configurations and risky settings** across 9 probes: Git, SSH, npm, environment variables, shell history, cloud credentials (AWS/GCP/Azure), JetBrains IDEs, GitHub CLI, and AI CLI tools. +* **Dev tool configurations and risky settings** across 10 probes: Git, SSH, npm, environment variables, shell history, cloud credentials (AWS/GCP/Azure), JetBrains IDEs, GitHub CLI, AI CLI tools and Docker. * **Secret locations (metadata only)**: presence of tokens, keys, and credentials in config files, env vars, and history—detected by 8 secret detectors—**never the secret values**. For detailed documentation on each probe and detector, see the [Bagel docs site](https://boostsecurityio.github.io/bagel/). @@ -173,6 +173,8 @@ probes: enabled: true ai_cli: enabled: true + docker: + enabled: true privacy: redact_paths: [] exclude_env_prefixes: [] @@ -241,17 +243,18 @@ Each probe declares its scope (user/system), paths touched, env vars read, and r ### Current Probes -| Probe | Description | What it checks | -|-------|-------------|----------------| -| `git` | Git configuration security | SSL verification disabled, SSH config issues (StrictHostKeyChecking, UserKnownHostsFile), plaintext credential storage (`credential.helper=store`), dangerous protocols (ext, fd, file), fsck disabled, proxy settings, custom hooks path | -| `ssh` | SSH configuration and key security | `StrictHostKeyChecking=no`, `UserKnownHostsFile=/dev/null`, `ForwardAgent=yes`, private key file permissions, unencrypted private keys | -| `npm` | NPM/Yarn configuration | `.npmrc` and `.yarnrc` files: `strict-ssl=false`, HTTP (non-HTTPS) registries, `always-auth` settings | -| `env` | Environment variables and dotfiles | Environment variables, shell config files (`.bashrc`, `.zshrc`), `.env` files for embedded secrets | -| `shell_history` | Shell history files | `.bash_history`, `.zsh_history` for secrets in command history | -| `cloud` | Cloud provider credentials | AWS (`~/.aws/config`, `~/.aws/credentials`), GCP (`~/.config/gcloud/`), Azure config files | -| `jetbrains` | JetBrains IDE configuration | JetBrains IDE workspace files and configuration for embedded secrets | -| `gh` | GitHub CLI | GitHub CLI authentication tokens and configuration | -| `ai_cli` | AI CLI tools | Credential files and chat logs for Gemini, Codex, Claude, and OpenCode | +| Probe | Description | What it checks | +|-----------------|------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `git` | Git configuration security | SSL verification disabled, SSH config issues (StrictHostKeyChecking, UserKnownHostsFile), plaintext credential storage (`credential.helper=store`), dangerous protocols (ext, fd, file), fsck disabled, proxy settings, custom hooks path | +| `ssh` | SSH configuration and key security | `StrictHostKeyChecking=no`, `UserKnownHostsFile=/dev/null`, `ForwardAgent=yes`, private key file permissions, unencrypted private keys | +| `npm` | NPM/Yarn configuration | `.npmrc` and `.yarnrc` files: `strict-ssl=false`, HTTP (non-HTTPS) registries, `always-auth` settings | +| `env` | Environment variables and dotfiles | Environment variables, shell config files (`.bashrc`, `.zshrc`), `.env` files for embedded secrets | +| `shell_history` | Shell history files | `.bash_history`, `.zsh_history` for secrets in command history | +| `cloud` | Cloud provider credentials | AWS (`~/.aws/config`, `~/.aws/credentials`), GCP (`~/.config/gcloud/`), Azure config files | +| `jetbrains` | JetBrains IDE configuration | JetBrains IDE workspace files and configuration for embedded secrets | +| `gh` | GitHub CLI | GitHub CLI authentication tokens and configuration | +| `ai_cli` | AI CLI tools | Credential files and chat logs for Gemini, Codex, Claude, and OpenCode | +| `docker` | Docker | Docker config files with registry creds in cleartext | ### Current Detectors diff --git a/cmd/bagel/scan.go b/cmd/bagel/scan.go index b1b9bbb..5f5200f 100644 --- a/cmd/bagel/scan.go +++ b/cmd/bagel/scan.go @@ -119,6 +119,7 @@ func initializeProbes(cfg *models.Config) []probe.Probe { registry.Register(detector.NewCloudCredentialsDetector()) registry.Register(detector.NewGenericAPIKeyDetector()) registry.Register(detector.NewJWTDetector()) + registry.Register(detector.NewDockerCredentialsDetector()) // Add more detectors here as they are implemented: // registry.Register(detector.NewSlackTokenDetector()) // etc. @@ -168,5 +169,10 @@ func initializeProbes(cfg *models.Config) []probe.Probe { probes = append(probes, probe.NewAICliProbe(cfg.Probes.AICli, registry)) } + // Docker credentials probe + if cfg.Probes.DockerCreds.Enabled { + probes = append(probes, probe.NewDockerCredsProbe(cfg.Probes.DockerCreds, registry)) + } + return probes } diff --git a/pkg/config/config.go b/pkg/config/config.go index 127ed4e..b35e25f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -66,6 +66,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("probes.jetbrains.enabled", true) v.SetDefault("probes.gh.enabled", true) v.SetDefault("probes.ai_cli.enabled", true) + v.SetDefault("probes.docker.enabled", true) v.SetDefault("output.include_file_hashes", false) v.SetDefault("output.include_file_content", false) @@ -142,7 +143,10 @@ func setDefaults(v *viper.Viper) { }, "type": "glob"}, // Docker - {"name": "docker_config", "patterns": []string{".docker/config.json"}, "type": "glob"}, + {"name": "docker_config", "patterns": []string{ + ".docker/config.json", + ".config/containers/auth.json", + }, "type": "glob"}, // Kubernetes {"name": "kubeconfig", "patterns": []string{".kube/config"}, "type": "glob"}, diff --git a/pkg/detector/docker_creds.go b/pkg/detector/docker_creds.go new file mode 100644 index 0000000..81d5056 --- /dev/null +++ b/pkg/detector/docker_creds.go @@ -0,0 +1,70 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package detector + +import ( + "encoding/json" + "fmt" + + "github.com/boostsecurityio/bagel/pkg/models" +) + +// DockerCredentialsDetector detects cleartext secrets in Docker config files +type DockerCredentialsDetector struct{} + +func NewDockerCredentialsDetector() *DockerCredentialsDetector { + return &DockerCredentialsDetector{} +} + +// Name returns the detector name +func (dcd *DockerCredentialsDetector) Name() string { + return "docker-credentials" +} + +// Detect scans content for Docker credentials and returns findings +func (dcd *DockerCredentialsDetector) Detect(content string, ctx *models.DetectionContext) []models.Finding { + var findings []models.Finding + regsWithCreds, err := regsWithCreds(content) + if err != nil { + return findings + } + for _, registry := range regsWithCreds { + findings = append(findings, dcd.createFinding(ctx.Source, registry)) + } + return findings +} + +// createFinding creates a finding for a detected Docker cred +func (dcd *DockerCredentialsDetector) createFinding(location string, registry string) models.Finding { + return models.Finding{ + ID: fmt.Sprintf("%-%s", dcd.Name(), registry), + Probe: dcd.Name(), + Title: "Docker credentials found", + Message: fmt.Sprintf("%s contains cleartext credentials for %s. Consider using a credential helper.", location, registry), + Path: location, + Severity: "high", + } +} + +func regsWithCreds(fileContents string) ([]string, error) { + var registries []string + var config map[string]interface{} + if err := json.Unmarshal([]byte(fileContents), &config); err != nil { + return registries, err + } + auths, ok := config["auths"].(map[string]interface{}) + if !ok { + return registries, nil + } + for registry, settings := range auths { + propsForRegistry, ok := settings.(map[string]interface{}) + if !ok { + continue + } + if propsForRegistry["auth"] != nil { + registries = append(registries, registry) + } + } + return registries, nil +} diff --git a/pkg/detector/docker_creds_test.go b/pkg/detector/docker_creds_test.go new file mode 100644 index 0000000..61fb2aa --- /dev/null +++ b/pkg/detector/docker_creds_test.go @@ -0,0 +1,52 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package detector + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var configFileWithCreds = `{ + "auths": { + "dhi.io": { + "auth": "supersecret" + }, + "docker.io": {}, + "ghcr.io": {} + }, + "credHelpers": { + "dhi.io": "osxkeychain", + "ghcr.io": "osxkeychain", + "docker.io": "osxkeychain" + } +} +` + +var configFileWithoutCreds = `{ + "auths": { + "dhi.io": {}, + "docker.io": {}, + "ghcr.io": {} + }, + "credHelpers": { + "dhi.io": "osxkeychain", + "ghcr.io": "osxkeychain", + "docker.io": "osxkeychain" + } +} +` + +func TestParsingFileWithCreds(t *testing.T) { + expected := []string{"dhi.io"} + actual, _ := regsWithCreds(configFileWithCreds) + assert.Equal(t, expected, actual) +} + +func TestParsingFileWithoutCreds(t *testing.T) { + var expected []string + actual, _ := regsWithCreds(configFileWithoutCreds) + assert.Equal(t, expected, actual) +} diff --git a/pkg/models/config.go b/pkg/models/config.go index 0850477..4c05f6b 100644 --- a/pkg/models/config.go +++ b/pkg/models/config.go @@ -30,6 +30,7 @@ type ProbeConfig struct { JetBrains ProbeSettings `yaml:"jetbrains" mapstructure:"jetbrains"` GH ProbeSettings `yaml:"gh" mapstructure:"gh"` AICli ProbeSettings `yaml:"ai_cli" mapstructure:"ai_cli"` + DockerCreds ProbeSettings `yaml:"docker" mapstructure:"docker"` } // ProbeSettings contains settings for a specific probe diff --git a/pkg/probe/docker_creds.go b/pkg/probe/docker_creds.go new file mode 100644 index 0000000..508414c --- /dev/null +++ b/pkg/probe/docker_creds.go @@ -0,0 +1,78 @@ +// Copyright (C) 2026 boostsecurity.io +// SPDX-License-Identifier: GPL-3.0-or-later + +package probe + +import ( + "context" + "os" + + "github.com/boostsecurityio/bagel/pkg/detector" + "github.com/boostsecurityio/bagel/pkg/fileindex" + "github.com/boostsecurityio/bagel/pkg/models" + "github.com/rs/zerolog/log" +) + +// DockerCredsProbe checks for Docker registry creds in cleartext +type DockerCredsProbe struct { + enabled bool + config models.ProbeSettings + fileIndex *fileindex.FileIndex + detectorRegistry *detector.Registry +} + +// NewDockerCredsProbe creates a new cloud credentials probe +func NewDockerCredsProbe(config models.ProbeSettings, registry *detector.Registry) *DockerCredsProbe { + return &DockerCredsProbe{ + enabled: config.Enabled, + config: config, + detectorRegistry: registry, + } +} + +// Name returns the probe name +func (dcp *DockerCredsProbe) Name() string { + return "Docker credentials" +} + +// IsEnabled returns whether the probe is enabled +func (dcp *DockerCredsProbe) IsEnabled() bool { + return dcp.enabled +} + +// SetFileIndex sets the file index for this probe (implements FileIndexAware) +func (dcp *DockerCredsProbe) SetFileIndex(index *fileindex.FileIndex) { + dcp.fileIndex = index +} + +// Execute runs the Docker credentials probe +func (dcp *DockerCredsProbe) Execute(ctx context.Context) ([]models.Finding, error) { + var findings []models.Finding + + // If file index is not available, skip probe + if dcp.fileIndex == nil { + log.Ctx(ctx).Warn(). + Str("probe", dcp.Name()). + Msg("File index not available, skipping Docker credentials probe") + return findings, nil + } + + locations := dcp.fileIndex.Get("docker_config") + for _, location := range locations { + _, err := os.Stat(location) + if err != nil { + continue + } + fileContents, err := os.ReadFile(location) + if err != nil { + return findings, err + } + detCtx := models.NewDetectionContext(models.NewDetectionContextInput{ + Source: "file:" + location, + ProbeName: dcp.Name(), + }) + detectedSecrets := dcp.detectorRegistry.DetectAll(string(fileContents), detCtx) + findings = append(findings, detectedSecrets...) + } + return findings, nil +}