fix(publish): prompt on sensitive-looking env literals

Replace the "flag any literal env var" check with a key-name heuristic
backed by the upstream DefangLabs keyword detector (password, secret,
token, api_key, …), and convert the hard error into a prompt matching
the existing checkForBindMount / checkForSensitiveData UX. --with-env
silences the env prompt; literal config.content gets its own prompt.

The previous check flagged benign vars like LOG_LEVEL=info, blocking
the 99% case, while still missing low-entropy real secrets the
existing secret-detector skips (MYSQL_ROOT_PASSWORD=toto slips through
on entropy ~1.5).

Refs: docker/compose#13394

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2026-04-28 14:15:36 +02:00
parent 60584e72b2
commit 542714c94c
No known key found for this signature in database
GPG key ID: 902F56FC16D4AA89
6 changed files with 745 additions and 54 deletions

View file

@ -25,8 +25,10 @@ import (
"fmt"
"io"
"os"
"slices"
"strings"
"github.com/DefangLabs/secret-detector/pkg/detectors/keyword"
"github.com/DefangLabs/secret-detector/pkg/scanner"
"github.com/DefangLabs/secret-detector/pkg/secrets"
"github.com/compose-spec/compose-go/v2/loader"
@ -341,37 +343,285 @@ func (s *composeService) preChecks(ctx context.Context, project *types.Project,
return false, err
}
}
err = s.checkEnvironmentVariables(project, options)
err = s.checkEnvironmentVariables(ctx, project, options)
if err != nil {
return false, err
}
return true, nil
}
func (s *composeService) checkEnvironmentVariables(project *types.Project, options api.PublishOptions) error {
errorList := map[string][]string{}
// envCheckFindings groups everything checkEnvironmentVariables surfaces to
// the user during publish pre-checks for env-related leak risks.
type envCheckFindings struct {
// services maps service name -> findings for that service. Only services
// with at least one finding are present.
services map[string]*serviceEnvFindings
// configsLiteralContent lists configs whose inline `content:` is a literal
// (not interpolation). Sorted alphabetically. config.content is decoupled
// from --with-env because the flag is documented as controlling environment
// variable publishing only.
configsLiteralContent []string
}
for _, service := range project.Services {
if len(service.EnvFiles) > 0 {
errorList[service.Name] = append(errorList[service.Name], fmt.Sprintf("service %q has env_file declared.", service.Name))
type serviceEnvFindings struct {
hasEnvFile bool
// suspiciousKeys is the set of environment variable names whose literal
// values look sensitive, as classified by the upstream DefangLabs keyword
// detector (password, secret, token, api_key, …). A set is used because
// the same service may be visited across multiple compose files during
// the extends walk; callers convert to a sorted slice via sortedKeys
// when surfacing to the user.
suspiciousKeys map[string]struct{}
}
// sortedSuspiciousKeys returns the suspicious env var names alphabetically
// sorted for stable output.
func (f *serviceEnvFindings) sortedSuspiciousKeys() []string {
return sortedMapKeys(f.suspiciousKeys)
}
func sortedMapKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}
func (f *envCheckFindings) hasEnvFinding() bool {
for _, svc := range f.services {
if svc.hasEnvFile || len(svc.suspiciousKeys) > 0 {
return true
}
}
return false
}
// checkEnvironmentVariables walks every compose file that will be serialized
// into the OCI artifact (the top-level files plus any local extends parents)
// and prompts the user to confirm before publishing:
//
// 1. service env_file declarations and literal environment values whose key
// name looks sensitive (password, secret, token, api_key, …) — silenced
// by --with-env;
// 2. literal inline config.content — always prompts (decoupled from
// --with-env, which is documented to cover env vars only).
//
// Interpolated values like "${SECRET}" or "$VAR" are preserved as placeholders
// in the published YAML and don't leak the resolved value; the keyword
// detector's value regex skips them automatically.
func (s *composeService) checkEnvironmentVariables(ctx context.Context, project *types.Project, options api.PublishOptions) error {
if len(project.ComposeFiles) == 0 {
return nil
}
findings, err := collectEnvCheckFindings(ctx, project)
if err != nil {
return err
}
if !options.WithEnvironment && findings.hasEnvFinding() {
if err := s.confirmOrCancel(buildEnvPromptMessage(findings.services)); err != nil {
return err
}
}
if !options.WithEnvironment && len(errorList) > 0 {
errorMsgSuffix := "To avoid leaking sensitive data, you must either explicitly allow the sending of environment variables by using the --with-env flag,\n" +
"or remove sensitive data from your Compose configuration"
var errorMsg strings.Builder
for _, errors := range errorList {
for _, err := range errors {
fmt.Fprintf(&errorMsg, "%s\n", err)
}
if len(findings.configsLiteralContent) > 0 {
if err := s.confirmOrCancel(buildConfigContentPromptMessage(findings.configsLiteralContent)); err != nil {
return err
}
return fmt.Errorf("%s%s", errorMsg.String(), errorMsgSuffix)
}
return nil
}
// confirmOrCancel runs an interactive yes/no prompt and returns:
// - the prompt's error verbatim, if it failed;
// - api.ErrCanceled if the user declined;
// - nil if the user accepted.
func (s *composeService) confirmOrCancel(message string) error {
confirm, err := s.prompt(message, false)
if err != nil {
return err
}
if !confirm {
return api.ErrCanceled
}
return nil
}
// collectEnvCheckFindings walks every compose file scheduled for publication
// (top-level files plus any local extends parents discovered along the way)
// and aggregates per-service and per-config findings. The walk mirrors
// processExtends so coverage matches what is actually serialized into the OCI
// artifact.
func collectEnvCheckFindings(ctx context.Context, project *types.Project) (*envCheckFindings, error) {
findings := &envCheckFindings{services: map[string]*serviceEnvFindings{}}
literalCfgs := map[string]struct{}{}
keywordDetector := keyword.NewDetector("0")
seen := map[string]struct{}{}
queue := slices.Clone(project.ComposeFiles)
for len(queue) > 0 {
file := queue[0]
queue = queue[1:]
if _, ok := seen[file]; ok {
continue
}
seen[file] = struct{}{}
unresolved, err := loadUnresolvedFile(ctx, project, file)
if err != nil {
return nil, fmt.Errorf("failed to load compose file %s: %w", file, err)
}
for _, service := range unresolved.Services {
recordServiceEnvFindings(findings.services, keywordDetector, service)
if parent := localExtendsParent(service); parent != "" {
queue = append(queue, parent)
}
}
for name, config := range unresolved.Configs {
// config.Environment is a variable *name* (only the name is
// published, not its resolved value) so it is not a leak. Inline
// config.Content is what ends up in the artifact. compose-go
// enforces that file, environment, and content are mutually
// exclusive. The map key is the name as written in the compose
// file; config.Name is the project-namespaced version, which is
// less helpful when surfaced to the user.
if config.Content != "" && configContentLooksLiteral(config.Content, keywordDetector) {
literalCfgs[name] = struct{}{}
}
}
}
if len(literalCfgs) > 0 {
findings.configsLiteralContent = sortedMapKeys(literalCfgs)
}
return findings, nil
}
func recordServiceEnvFindings(services map[string]*serviceEnvFindings, detector secrets.Detector, service types.ServiceConfig) {
envValues := map[string]string{}
for key, value := range service.Environment {
if value == nil {
continue
}
envValues[key] = replaceDollarEscape(*value)
}
hits, _ := detector.ScanMap(envValues)
if len(hits) == 0 && len(service.EnvFiles) == 0 {
return
}
f := services[service.Name]
if f == nil {
f = &serviceEnvFindings{suspiciousKeys: map[string]struct{}{}}
services[service.Name] = f
}
if len(service.EnvFiles) > 0 {
f.hasEnvFile = true
}
for _, hit := range hits {
f.suspiciousKeys[hit.Key] = struct{}{}
}
}
// configContentLooksLiteral returns true when the inline config.content has
// a literal portion that would be published as-is, leaking the value to
// consumers of the OCI artifact.
//
// We piggyback on the keyword detector's value regex (`[^${\s].+[^}\s]`) by
// passing a fake "password" key to ScanMap — the regex isn't exported
// directly, only via the key+value match path. The regex excludes values
// starting with `$` (`${VAR}`/`$VAR` interpolation), ending with `}`
// (templates like `key=${SECRET}`), or shorter than 3 chars, which neatly
// matches our notion of "looks like a template, not a literal".
func configContentLooksLiteral(content string, detector secrets.Detector) bool {
hits, _ := detector.ScanMap(map[string]string{"password": replaceDollarEscape(content)})
return len(hits) > 0
}
// replaceDollarEscape substitutes the compose-spec `$$` escape (which
// represents a literal `$` in the resolved value) with a placeholder. The
// placeholder is `X` rather than `$` because the keyword detector's value
// regex excludes any value beginning with `$`; using `$` would mask the
// literal we're trying to flag. Any non-special char would do — we picked
// `X` for readability.
func replaceDollarEscape(value string) string {
return strings.ReplaceAll(value, "$$", "X")
}
// localExtendsParent returns the path of an extends parent file that exists on
// disk, or "" when the service does not extend or extends a remote resource.
func localExtendsParent(service types.ServiceConfig) string {
if service.Extends == nil || service.Extends.File == "" {
return ""
}
if _, err := os.Stat(service.Extends.File); err != nil {
return ""
}
return service.Extends.File
}
func buildEnvPromptMessage(services map[string]*serviceEnvFindings) string {
var b strings.Builder
b.WriteString("you are about to publish env-related declarations within your OCI artifact.\n")
b.WriteString("env_file paths and literal values for sensitive-looking keys are embedded as-is in the published YAML;\n")
b.WriteString("interpolated values like \"${VAR}\" are kept symbolic and have already been excluded.\n")
for _, name := range sortedMapKeys(services) {
f := services[name]
if f.hasEnvFile {
fmt.Fprintf(&b, " service %q: env_file declared\n", name)
}
if keys := f.sortedSuspiciousKeys(); len(keys) > 0 {
quoted := make([]string, len(keys))
for i, k := range keys {
quoted[i] = fmt.Sprintf("%q", k)
}
fmt.Fprintf(&b, " service %q: literal value for %s\n", name, strings.Join(quoted, ", "))
}
}
b.WriteString("Use --with-env to silence this prompt and always publish env declarations.\n")
b.WriteString("Are you ok to publish these env declarations?")
return b.String()
}
func buildConfigContentPromptMessage(configs []string) string {
var b strings.Builder
b.WriteString("you are about to publish literal inline config content within your OCI artifact.\n")
for _, name := range configs {
fmt.Fprintf(&b, " config %q\n", name)
}
b.WriteString("Are you ok to publish these config contents?")
return b.String()
}
// loadUnresolvedFile loads a single compose file with interpolation and
// environment resolution skipped, so callers can inspect raw user-provided
// values. Used by both checkEnvironmentVariables and composeFileAsByteReader.
func loadUnresolvedFile(ctx context.Context, project *types.Project, filePath string) (*types.Project, error) {
return loader.LoadWithContext(ctx, types.ConfigDetails{
WorkingDir: project.WorkingDir,
Environment: project.Environment,
ConfigFiles: []types.ConfigFile{{Filename: filePath}},
}, func(options *loader.Options) {
options.SkipValidation = true
options.SkipExtends = true
options.SkipConsistencyCheck = true
options.ResolvePaths = true
// SkipInclude mirrors processFile: include directives stay symbolic in
// the published artifact, so included content must not be inspected
// here either (otherwise we'd flag literals that never ship).
options.SkipInclude = true
options.SkipInterpolation = true
options.SkipResolveEnvironment = true
options.Profiles = project.Profiles
})
}
func envFileLayers(files map[string]string) []v1.Descriptor {
var layers []v1.Descriptor
for file, hash := range files {
@ -473,31 +723,10 @@ func (s *composeService) checkForSensitiveData(ctx context.Context, project *typ
}
func composeFileAsByteReader(ctx context.Context, filePath string, project *types.Project) (io.Reader, error) {
composeFile, err := os.ReadFile(filePath)
base, err := loadUnresolvedFile(ctx, project, filePath)
if err != nil {
return nil, fmt.Errorf("failed to open compose file %s: %w", filePath, err)
return nil, fmt.Errorf("failed to load compose file %s: %w", filePath, err)
}
base, err := loader.LoadWithContext(ctx, types.ConfigDetails{
WorkingDir: project.WorkingDir,
Environment: project.Environment,
ConfigFiles: []types.ConfigFile{
{
Filename: filePath,
Content: composeFile,
},
},
}, func(options *loader.Options) {
options.SkipValidation = true
options.SkipExtends = true
options.SkipConsistencyCheck = true
options.ResolvePaths = true
options.SkipInterpolation = true
options.SkipResolveEnvironment = true
})
if err != nil {
return nil, err
}
in, err := base.MarshalYAML()
if err != nil {
return nil, err

View file

@ -19,7 +19,9 @@ package compose
import (
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/compose-spec/compose-go/v2/loader"
@ -134,6 +136,434 @@ func Test_preChecks_sensitive_data_detected_decline(t *testing.T) {
assert.Equal(t, accept, false)
}
// --- collectEnvCheckFindings: pure detection logic ---
func loadProjectForTest(t *testing.T, files map[string]string) *types.Project {
t.Helper()
dir := t.TempDir()
for name, content := range files {
path := filepath.Join(dir, name)
assert.NilError(t, os.MkdirAll(filepath.Dir(path), 0o755))
assert.NilError(t, os.WriteFile(path, []byte(content), 0o600))
}
composePath := filepath.Join(dir, "compose.yaml")
project, err := loader.LoadWithContext(t.Context(), types.ConfigDetails{
WorkingDir: dir,
Environment: types.Mapping{},
ConfigFiles: []types.ConfigFile{{Filename: composePath}},
}, func(options *loader.Options) {
options.SetProjectName("test", true)
})
assert.NilError(t, err)
project.ComposeFiles = []string{composePath}
return project
}
func Test_collectEnvCheckFindings(t *testing.T) {
tests := []struct {
name string
files map[string]string
wantSuspicious map[string][]string // service -> sorted suspicious keys
wantEnvFile []string // services with env_file
wantLiteralCfgs []string // config names with literal content
}{
{
name: "benign literals are silent",
files: map[string]string{
"compose.yaml": `name: test
services:
web:
image: alpine
environment:
LOG_LEVEL: info
NODE_ENV: production
PORT: "8080"
`,
},
},
{
name: "interpolated values are silent even on suspicious keys",
files: map[string]string{
"compose.yaml": `name: test
services:
web:
image: alpine
environment:
DB_PASSWORD: "${DB_PASSWORD}"
API_KEY: "$API_KEY"
`,
},
},
{
name: "literal value on suspicious key is flagged",
files: map[string]string{
"compose.yaml": `name: test
services:
db:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: toto
MYSQL_DATABASE: appdb
`,
},
wantSuspicious: map[string][]string{
"db": {"MYSQL_ROOT_PASSWORD"},
},
},
{
name: "demo placeholder changeme is flagged (security: literal still leaks)",
files: map[string]string{
"compose.yaml": `name: test
services:
demo:
image: postgres
environment:
DB_PASSWORD: changeme
`,
},
wantSuspicious: map[string][]string{
"demo": {"DB_PASSWORD"},
},
},
{
name: "multiple suspicious keys on one service are aggregated and sorted",
files: map[string]string{
"compose.yaml": `name: test
services:
api:
image: alpine
environment:
DB_PASSWORD: toto
API_KEY: foo
DEBUG: "1"
`,
},
// DEBUG is benign — only suspicious-named keys appear.
wantSuspicious: map[string][]string{
"api": {"API_KEY", "DB_PASSWORD"},
},
},
{
name: "nil-valued env (KEY without =) is silent",
files: map[string]string{
"compose.yaml": `name: test
services:
web:
image: alpine
environment:
- PASSWORD
`,
},
},
{
name: "env_file declaration is reported separately",
files: map[string]string{
"compose.yaml": `name: test
services:
legacy:
image: alpine
env_file:
- ./app.env
`,
"app.env": "FOO=bar\n",
},
wantEnvFile: []string{"legacy"},
},
{
name: "literal config.content is flagged",
files: map[string]string{
"compose.yaml": `name: test
services:
app:
image: alpine
configs:
cfg:
content: |
api_key=plaintext
`,
},
wantLiteralCfgs: []string{"cfg"},
},
{
name: "interpolated config.content is silent",
files: map[string]string{
"compose.yaml": `name: test
services:
app:
image: alpine
configs:
cfg:
content: "key=${SECRET}"
`,
},
},
{
name: "config.environment is silent (only the var name is published)",
files: map[string]string{
"compose.yaml": `name: test
services:
app:
image: alpine
configs:
cfg:
environment: HARDCODED
`,
},
},
{
name: "compose-spec $$ escape on suspicious key is flagged (literal $ leaks)",
files: map[string]string{
"compose.yaml": `name: test
services:
db:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: "$$literal"
`,
},
wantSuspicious: map[string][]string{
"db": {"MYSQL_ROOT_PASSWORD"},
},
},
{
name: "embedded $$ in middle of value is flagged (pa$$word resolves to pa$word)",
files: map[string]string{
"compose.yaml": `name: test
services:
db:
image: mysql
environment:
DB_PASSWORD: "pa$$word"
`,
},
wantSuspicious: map[string][]string{
"db": {"DB_PASSWORD"},
},
},
{
name: "single $ remains interpolation (escape fix does not regress this)",
files: map[string]string{
"compose.yaml": `name: test
services:
web:
image: alpine
environment:
DB_PASSWORD: "$VAR"
API_KEY: "${TOKEN}"
`,
},
},
{
name: "extends walks parent file and reports inherited literals",
files: map[string]string{
"compose.yaml": `name: test
services:
api:
extends:
file: ./base.yaml
service: api-base
`,
"base.yaml": `services:
api-base:
image: alpine
environment:
INHERITED_PASSWORD: toto
`,
},
// The extends walk surfaces parent-file findings under the parent's
// service name, since that's what gets serialized into the OCI artifact.
wantSuspicious: map[string][]string{
"api-base": {"INHERITED_PASSWORD"},
},
},
{
name: "extends parent unrelated services are also reported (they leak too)",
files: map[string]string{
"compose.yaml": `name: test
services:
api:
extends:
file: ./base.yaml
service: api-base
`,
"base.yaml": `services:
api-base:
image: alpine
environment:
INHERITED_PASSWORD: shared-toto
unrelated:
image: alpine
environment:
UNRELATED_SECRET: lonely-toto
`,
},
wantSuspicious: map[string][]string{
"api-base": {"INHERITED_PASSWORD"},
"unrelated": {"UNRELATED_SECRET"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project := loadProjectForTest(t, tt.files)
findings, err := collectEnvCheckFindings(t.Context(), project)
assert.NilError(t, err)
gotSuspicious := map[string][]string{}
var gotEnvFile []string
for name, f := range findings.services {
if keys := f.sortedSuspiciousKeys(); len(keys) > 0 {
gotSuspicious[name] = keys
}
if f.hasEnvFile {
gotEnvFile = append(gotEnvFile, name)
}
}
slices.Sort(gotEnvFile)
if tt.wantSuspicious == nil {
tt.wantSuspicious = map[string][]string{}
}
assert.DeepEqual(t, tt.wantSuspicious, gotSuspicious)
assert.DeepEqual(t, tt.wantEnvFile, gotEnvFile)
assert.DeepEqual(t, tt.wantLiteralCfgs, findings.configsLiteralContent)
})
}
}
// --- checkEnvironmentVariables: prompt orchestration ---
type fakePrompt struct {
answers []bool // queued answers; consumed FIFO
prompts []string // captured prompt messages
}
func (p *fakePrompt) handler(message string, _ bool) (bool, error) {
p.prompts = append(p.prompts, message)
if len(p.answers) == 0 {
return true, nil
}
a := p.answers[0]
p.answers = p.answers[1:]
return a, nil
}
func Test_checkEnvironmentVariables_silent_when_no_findings(t *testing.T) {
project := loadProjectForTest(t, map[string]string{
"compose.yaml": `name: test
services:
web:
image: alpine
environment:
LOG_LEVEL: info
DB_HOST: "${DATABASE_HOST}"
`,
})
prompt := &fakePrompt{}
svc := &composeService{prompt: prompt.handler}
err := svc.checkEnvironmentVariables(t.Context(), project, api.PublishOptions{})
assert.NilError(t, err)
assert.Equal(t, len(prompt.prompts), 0, "no prompt expected for benign config")
}
func Test_checkEnvironmentVariables_prompts_on_suspicious_literal(t *testing.T) {
project := loadProjectForTest(t, map[string]string{
"compose.yaml": `name: test
services:
db:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: toto
`,
})
prompt := &fakePrompt{answers: []bool{true}}
svc := &composeService{prompt: prompt.handler}
err := svc.checkEnvironmentVariables(t.Context(), project, api.PublishOptions{})
assert.NilError(t, err)
assert.Equal(t, len(prompt.prompts), 1, "exactly one env-related prompt")
assert.Assert(t, strings.Contains(prompt.prompts[0], `service "db"`))
assert.Assert(t, strings.Contains(prompt.prompts[0], "MYSQL_ROOT_PASSWORD"))
}
func Test_checkEnvironmentVariables_decline_returns_ErrCanceled(t *testing.T) {
project := loadProjectForTest(t, map[string]string{
"compose.yaml": `name: test
services:
db:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: toto
`,
})
prompt := &fakePrompt{answers: []bool{false}}
svc := &composeService{prompt: prompt.handler}
err := svc.checkEnvironmentVariables(t.Context(), project, api.PublishOptions{})
assert.Assert(t, errors.Is(err, api.ErrCanceled),
"decline should return api.ErrCanceled, got: %v", err)
}
func Test_checkEnvironmentVariables_with_env_silences_env_prompt(t *testing.T) {
// --with-env should silence env_file + literal-env prompts, but config.content
// has its own prompt path that runs regardless.
project := loadProjectForTest(t, map[string]string{
"compose.yaml": `name: test
services:
db:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: toto
legacy:
image: alpine
env_file:
- ./app.env
configs:
cfg:
content: |
api_key=plaintext
`,
"app.env": "FOO=bar\n",
})
prompt := &fakePrompt{answers: []bool{true}}
svc := &composeService{prompt: prompt.handler}
err := svc.checkEnvironmentVariables(t.Context(), project, api.PublishOptions{WithEnvironment: true})
assert.NilError(t, err)
assert.Equal(t, len(prompt.prompts), 1, "only the config.content prompt should fire")
assert.Assert(t, strings.Contains(prompt.prompts[0], `config "cfg"`))
}
func Test_checkEnvironmentVariables_two_prompts_when_env_and_config(t *testing.T) {
project := loadProjectForTest(t, map[string]string{
"compose.yaml": `name: test
services:
db:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: toto
configs:
cfg:
content: |
api_key=plaintext
`,
})
prompt := &fakePrompt{answers: []bool{true, true}}
svc := &composeService{prompt: prompt.handler}
err := svc.checkEnvironmentVariables(t.Context(), project, api.PublishOptions{})
assert.NilError(t, err)
assert.Equal(t, len(prompt.prompts), 2, "expected env prompt then config.content prompt")
}
func Test_publish_decline_returns_ErrCanceled(t *testing.T) {
project := &types.Project{
Services: types.Services{

View file

@ -2,6 +2,6 @@ services:
serviceA:
image: "alpine:3.12"
environment:
- "FOO=bar"
- "MYSQL_ROOT_PASSWORD=bar"
serviceB:
image: "alpine:3.12"

View file

@ -0,0 +1,5 @@
services:
serviceA:
image: "alpine:3.12"
environment:
TEST: "${SOMEVAR}"

View file

@ -2,10 +2,10 @@ services:
serviceA:
image: "alpine:3.12"
environment:
- "FOO=bar"
- "DB_PASSWORD=bar"
serviceB:
image: "alpine:3.12"
env_file:
- publish.env
environment:
- "BAR=baz"
- "API_KEY=baz"

View file

@ -29,21 +29,48 @@ func TestPublishChecks(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "compose-e2e-explicit-profiles"
t.Run("publish error env_file", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/publish/compose-env-file.yml",
"-p", projectName, "publish", "test/test")
res.Assert(t, icmd.Expected{ExitCode: 1, Err: `service "serviceA" has env_file declared.
To avoid leaking sensitive data,`})
t.Run("publish prompt env_file declined", func(t *testing.T) {
cmd := c.NewDockerComposeCmd(t, "-f", "./fixtures/publish/compose-env-file.yml",
"-p", projectName, "publish", "test/test", "--dry-run")
cmd.Stdin = strings.NewReader("n\n")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{ExitCode: 130})
out := res.Combined()
assert.Assert(t, strings.Contains(out, "you are about to publish env-related declarations within your OCI artifact."), out)
assert.Assert(t, strings.Contains(out, `service "serviceA": env_file declared`), out)
assert.Assert(t, strings.Contains(out, "Are you ok to publish these env declarations?"), out)
assert.Assert(t, !strings.Contains(out, "test/test published"), out)
})
t.Run("publish multiple errors env_file and environment", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/publish/compose-multi-env-config.yml",
"-p", projectName, "publish", "test/test")
// we don't in which order the services will be loaded, so we can't predict the order of the error messages
assert.Assert(t, strings.Contains(res.Combined(), `service "serviceB" has env_file declared.`), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), `To avoid leaking sensitive data, you must either explicitly allow the sending of environment variables by using the --with-env flag,
or remove sensitive data from your Compose configuration
`), res.Combined())
t.Run("publish prompt suspicious env declined", func(t *testing.T) {
cmd := c.NewDockerComposeCmd(t, "-f", "./fixtures/publish/compose-environment.yml",
"-p", projectName, "publish", "test/test", "--dry-run")
cmd.Stdin = strings.NewReader("n\n")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{ExitCode: 130})
out := res.Combined()
assert.Assert(t, strings.Contains(out, `service "serviceA": literal value for "MYSQL_ROOT_PASSWORD"`), out)
})
t.Run("publish success interpolated env", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/publish/compose-interpolated-env.yml",
"-p", projectName, "publish", "test/test", "-y", "--dry-run")
assert.Assert(t, strings.Contains(res.Combined(), "test/test publishing"), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), "test/test published"), res.Combined())
})
t.Run("publish prompt aggregates env_file and suspicious literals", func(t *testing.T) {
cmd := c.NewDockerComposeCmd(t, "-f", "./fixtures/publish/compose-multi-env-config.yml",
"-p", projectName, "publish", "test/test", "--dry-run")
cmd.Stdin = strings.NewReader("n\n")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{ExitCode: 130})
out := res.Combined()
// Order is non-deterministic between services; assert each line independently.
assert.Assert(t, strings.Contains(out, `service "serviceB": env_file declared`), out)
assert.Assert(t, strings.Contains(out, `service "serviceA": literal value for "DB_PASSWORD"`), out)
assert.Assert(t, strings.Contains(out, `service "serviceB": literal value for "API_KEY"`), out)
assert.Assert(t, strings.Contains(out, "Use --with-env to silence this prompt"), out)
})
t.Run("publish success environment", func(t *testing.T) {