fix(publish): honor env_file required: false for missing files

Signed-off-by: Ijtihed Kilani <ijtihedk@gmail.com>
This commit is contained in:
Ijtihed Kilani 2026-06-17 08:06:35 +03:00 committed by Guillaume Lours
parent 43922d55b0
commit 9cd844243f
2 changed files with 154 additions and 1 deletions

View file

@ -255,8 +255,21 @@ func processFile(ctx context.Context, file string, project *types.Project, extFi
}
for name, service := range base.Services {
for i, envFile := range service.EnvFiles {
// A real stat failure (e.g. permissions) is fatal, but a missing file is not:
// the project loader already rejects missing required env files before we get
// here, so an absent file at this point is an optional one.
_, statErr := os.Stat(envFile.Path)
if statErr != nil && !os.IsNotExist(statErr) {
return nil, fmt.Errorf("failed to access env file %s: %w", envFile.Path, statErr)
}
// The hash is derived from the path string alone, so the env_file is always
// rewritten to its opaque <hash>.env placeholder, even for a missing optional
// file, so the published artifact never leaks the local path. Only files that
// exist are registered for upload, mirroring the extends handling below.
hash := fmt.Sprintf("%x.env", sha256.Sum256([]byte(envFile.Path)))
envFiles[envFile.Path] = hash
if statErr == nil {
envFiles[envFile.Path] = hash
}
f, err = transform.ReplaceEnvFile(f, name, i, hash)
if err != nil {
return nil, err
@ -690,6 +703,15 @@ func (s *composeService) checkForSensitiveData(ctx context.Context, project *typ
for _, service := range project.Services {
// Check env files
for _, envFile := range service.EnvFiles {
if _, statErr := os.Stat(envFile.Path); statErr != nil {
if !os.IsNotExist(statErr) {
return nil, fmt.Errorf("failed to access env file %s: %w", envFile.Path, statErr)
}
if envFile.Required {
return nil, fmt.Errorf("env file %s not found", envFile.Path)
}
continue
}
findings, err := scan.ScanFile(envFile.Path)
if err != nil {
return nil, fmt.Errorf("failed to scan env file %s: %w", envFile.Path, err)

View file

@ -17,7 +17,9 @@
package compose
import (
"crypto/sha256"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
@ -136,6 +138,135 @@ func Test_preChecks_sensitive_data_detected_decline(t *testing.T) {
assert.Equal(t, accept, false)
}
func Test_processFile_optional_env_file_missing(t *testing.T) {
dir := t.TempDir()
composePath := filepath.Join(dir, "compose.yaml")
composeContent := `name: test
services:
web:
image: nginx
env_file:
- path: missing.env
required: false
`
assert.NilError(t, os.WriteFile(composePath, []byte(composeContent), 0o600))
project, err := loader.LoadWithContext(t.Context(), types.ConfigDetails{
WorkingDir: dir,
Environment: types.Mapping{},
ConfigFiles: []types.ConfigFile{{Filename: composePath}},
})
assert.NilError(t, err)
extFiles := map[string]string{}
envFiles := map[string]string{}
data, err := processFile(t.Context(), composePath, project, extFiles, envFiles)
assert.NilError(t, err, "optional missing env file should not cause error")
// The file is absent so nothing is registered for upload, but its path is still
// rewritten to the opaque <hash>.env placeholder so the published artifact never leaks
// the publisher's local path and stays consistent with the file-present case. The hash
// derives from the path string alone, so it is deterministic regardless of existence.
assert.Equal(t, len(envFiles), 0, "missing optional env file is not registered for upload")
envPath := project.Services["web"].EnvFiles[0].Path
hash := fmt.Sprintf("%x.env", sha256.Sum256([]byte(envPath)))
assert.Assert(t, strings.Contains(string(data), hash), "published YAML should reference the hash placeholder")
assert.Assert(t, !strings.Contains(string(data), "path: missing.env"), "published YAML must not leak the local path")
assert.Assert(t, strings.Contains(string(data), "required: false"), "optional flag must be preserved")
}
func Test_processFile_optional_env_file_present(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, "app.env")
assert.NilError(t, os.WriteFile(envPath, []byte("FOO=bar\n"), 0o600))
composePath := filepath.Join(dir, "compose.yaml")
composeContent := `name: test
services:
web:
image: nginx
env_file:
- path: app.env
required: false
`
assert.NilError(t, os.WriteFile(composePath, []byte(composeContent), 0o600))
project, err := loader.LoadWithContext(t.Context(), types.ConfigDetails{
WorkingDir: dir,
Environment: types.Mapping{},
ConfigFiles: []types.ConfigFile{{Filename: composePath}},
})
assert.NilError(t, err)
extFiles := map[string]string{}
envFiles := map[string]string{}
_, err = processFile(t.Context(), composePath, project, extFiles, envFiles)
assert.NilError(t, err)
assert.Equal(t, len(envFiles), 1, "present optional env file should be added")
}
func Test_checkForSensitiveData_optional_env_file_missing(t *testing.T) {
dir := t.TempDir()
project := &types.Project{
Services: types.Services{
"web": {
Name: "web",
Image: "nginx",
EnvFiles: []types.EnvFile{
{Path: filepath.Join(dir, "missing.env"), Required: false},
},
},
},
}
svc := &composeService{}
findings, err := svc.checkForSensitiveData(t.Context(), project)
assert.NilError(t, err, "optional missing env file should not cause error during scan")
assert.Equal(t, len(findings), 0)
}
func Test_checkForSensitiveData_optional_env_file_present(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, "secrets.env")
assert.NilError(t, os.WriteFile(envPath, []byte(`AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"`), 0o600))
project := &types.Project{
Services: types.Services{
"web": {
Name: "web",
Image: "nginx",
EnvFiles: []types.EnvFile{
{Path: envPath, Required: false},
},
},
},
}
svc := &composeService{}
findings, err := svc.checkForSensitiveData(t.Context(), project)
assert.NilError(t, err)
assert.Assert(t, len(findings) > 0, "present optional env file should still be scanned for secrets")
}
func Test_checkForSensitiveData_required_env_file_missing(t *testing.T) {
dir := t.TempDir()
project := &types.Project{
Services: types.Services{
"web": {
Name: "web",
Image: "nginx",
EnvFiles: []types.EnvFile{
{Path: filepath.Join(dir, "missing.env"), Required: true},
},
},
},
}
svc := &composeService{}
_, err := svc.checkForSensitiveData(t.Context(), project)
assert.ErrorContains(t, err, "not found", "required missing env file should fail")
}
// --- collectEnvCheckFindings: pure detection logic ---
func loadProjectForTest(t *testing.T, files map[string]string) *types.Project {