Skip to content

Commit 4fdc8f2

Browse files
authored
Merge branch 'main' into dependabot/github_actions/cicd-83381794f3
2 parents c712b09 + 5c24ea2 commit 4fdc8f2

7 files changed

Lines changed: 300 additions & 15 deletions

File tree

pkg/config/git_root.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import (
99
u "github.com/cloudposse/atmos/pkg/utils"
1010
)
1111

12+
// gitRootResolver resolves the Git repository root for a "!repo-root" style tag.
13+
// Overridden in tests to force the error path in applyGitRootBasePath, since
14+
// u.ProcessTagGitRoot's own default-value fallback otherwise swallows every
15+
// underlying git-detection failure when called with a non-empty default.
16+
var gitRootResolver = u.ProcessTagGitRoot
17+
1218
// applyGitRootBasePath automatically sets the base path to the Git repository root
1319
// when base_path is empty or set to the default value.
1420
//
@@ -56,7 +62,7 @@ func applyGitRootBasePath(atmosConfig *schema.AtmosConfiguration) error {
5662
}
5763

5864
// Resolve git root.
59-
gitRoot, err := u.ProcessTagGitRoot("!repo-root .")
65+
gitRoot, err := gitRootResolver("!repo-root .")
6066
if err != nil {
6167
log.Trace("Git root detection failed", "error", err)
6268
return err

pkg/config/load_config_args.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ func loadConfigFromCLIArgs(v *viper.Viper, configAndStacksInfo *schema.ConfigAnd
6868
preserveCaseSensitiveMaps(v, atmosConfig)
6969
restoreCaseSensitiveEnvMaps(atmosConfig)
7070

71+
// Apply git root discovery for default base path (same as the main LoadConfig
72+
// auto-discovery flow, load.go). Without this, a config loaded via --config/
73+
// --config-path with an empty (or ".") base_path never resolves to the git
74+
// repository root, breaking component/stack path resolution that the exact
75+
// same atmos.yaml would get right via plain auto-discovery (cloudposse/atmos#2863).
76+
if err := applyGitRootBasePath(atmosConfig); err != nil {
77+
log.Debug("Failed to apply git root base path", "error", err)
78+
// Don't fail config loading if this step fails, just log it (mirrors load.go).
79+
}
80+
7181
atmosConfig.CliConfigPath = connectPaths(configPaths)
7282
return nil
7383
}

pkg/config/load_config_args_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"errors"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -134,6 +135,114 @@ components:
134135
require.ErrorIs(t, err, edition.ErrInvalidEdition)
135136
}
136137

138+
// TestLoadConfigFromCLIArgs_AppliesGitRootBasePath reproduces cloudposse/atmos#2863:
139+
// loading config via --config/--config-path with an empty (or ".") base_path skipped
140+
// git-root discovery, because loadConfigFromCLIArgs never called applyGitRootBasePath,
141+
// unlike the main LoadConfig auto-discovery flow (load.go). This asserts the desired
142+
// end state -- BasePath resolved to the (mocked) git root -- for every CLI input
143+
// (--config file vs --config-path directory) and default base_path spelling ("" vs
144+
// "."), since applyGitRootBasePath treats both spellings as "unset" and either CLI
145+
// input can carry that config.
146+
func TestLoadConfigFromCLIArgs_AppliesGitRootBasePath(t *testing.T) {
147+
tests := []struct {
148+
name string
149+
basePath string
150+
useDir bool // true = AtmosConfigDirsFromArg, false = AtmosConfigFilesFromArg
151+
}{
152+
{name: "config file, empty base_path", basePath: `""`, useDir: false},
153+
{name: "config file, dot base_path", basePath: `"."`, useDir: false},
154+
{name: "config dir, empty base_path", basePath: `""`, useDir: true},
155+
{name: "config dir, dot base_path", basePath: `"."`, useDir: true},
156+
}
157+
158+
for _, tt := range tests {
159+
t.Run(tt.name, func(t *testing.T) {
160+
// The fixture atmos.yaml lives in its own temp dir, separate from the process
161+
// cwd: hasLocalAtmosConfig only inspects the cwd, never the --config file's
162+
// directory, so the two must be kept apart to exercise the real code path.
163+
configDir := t.TempDir()
164+
configFile := filepath.Join(configDir, "atmos.yaml")
165+
166+
configContent := fmt.Sprintf(`
167+
base_path: %s
168+
stacks:
169+
base_path: "stacks"
170+
components:
171+
terraform:
172+
base_path: "components/terraform"
173+
`, tt.basePath)
174+
require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0o644))
175+
176+
// cwd must be a separate, atmos-config-free directory so hasLocalAtmosConfig(cwd)
177+
// returns false and git-root discovery is not skipped.
178+
cwd := t.TempDir()
179+
t.Chdir(cwd)
180+
181+
// Mock git-root discovery deterministically (see pkg/utils/git.go ProcessTagGitRoot,
182+
// which short-circuits to TEST_GIT_ROOT when set, bypassing real git detection).
183+
t.Setenv("TEST_GIT_ROOT", "/mock/git/repo/root")
184+
185+
v := viper.New()
186+
v.SetConfigType("yaml")
187+
188+
configAndStacksInfo := &schema.ConfigAndStacksInfo{}
189+
if tt.useDir {
190+
configAndStacksInfo.AtmosConfigDirsFromArg = []string{configDir}
191+
} else {
192+
configAndStacksInfo.AtmosConfigFilesFromArg = []string{configFile}
193+
}
194+
195+
var atmosConfig schema.AtmosConfiguration
196+
err := loadConfigFromCLIArgs(v, configAndStacksInfo, &atmosConfig)
197+
require.NoError(t, err)
198+
199+
// Desired end state: BasePath resolves to the git root, exactly like the main
200+
// LoadConfig auto-discovery path does for the same atmos.yaml content.
201+
assert.Equal(t, "/mock/git/repo/root", atmosConfig.BasePath)
202+
})
203+
}
204+
}
205+
206+
// TestLoadConfigFromCLIArgs_GitRootBasePathErrorIsNonFatal verifies that a git-root
207+
// discovery failure is logged and swallowed rather than failing config loading, per
208+
// the "Don't fail config loading if this step fails, just log it" contract.
209+
func TestLoadConfigFromCLIArgs_GitRootBasePathErrorIsNonFatal(t *testing.T) {
210+
original := gitRootResolver
211+
var resolverCalled bool
212+
gitRootResolver = func(string) (string, error) {
213+
resolverCalled = true
214+
return "", errors.New("simulated git root detection failure")
215+
}
216+
defer func() { gitRootResolver = original }()
217+
218+
configDir := t.TempDir()
219+
configFile := filepath.Join(configDir, "atmos.yaml")
220+
require.NoError(t, os.WriteFile(configFile, []byte(`
221+
base_path: ""
222+
stacks:
223+
base_path: "stacks"
224+
components:
225+
terraform:
226+
base_path: "components/terraform"
227+
`), 0o644))
228+
229+
cwd := t.TempDir()
230+
t.Chdir(cwd)
231+
232+
v := viper.New()
233+
v.SetConfigType("yaml")
234+
235+
configAndStacksInfo := &schema.ConfigAndStacksInfo{
236+
AtmosConfigFilesFromArg: []string{configFile},
237+
}
238+
239+
var atmosConfig schema.AtmosConfiguration
240+
err := loadConfigFromCLIArgs(v, configAndStacksInfo, &atmosConfig)
241+
require.NoError(t, err, "a git-root resolution failure must not fail config loading")
242+
assert.True(t, resolverCalled, "gitRootResolver must have been invoked for this test to prove anything")
243+
assert.Empty(t, atmosConfig.BasePath, "base_path should remain unset when git-root discovery fails")
244+
}
245+
137246
func TestLoadConfigFromCLIArgs_InvalidConfigDir(t *testing.T) {
138247
v := viper.New()
139248
v.SetConfigType("yaml")

pkg/toolchain/pr_artifact.go

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -471,38 +471,65 @@ func extractZipFile(zipPath, destDir string) error {
471471
cleanDestDir := filepath.Clean(destDir) + string(os.PathSeparator)
472472

473473
for _, f := range r.File {
474+
// Reject any archive entry name containing "..", guarding the raw tainted value
475+
// directly (the shape CodeQL's go/zipslip query itself documents as the fix,
476+
// see the query's help text) rather than only a derived/split-component check --
477+
// sanitizeZipPath's per-component comparison isn't recognized as a sanitizer by
478+
// the query's dataflow model, so this guard is what actually clears the taint.
479+
if strings.Contains(f.Name, "..") {
480+
return fmt.Errorf(errZipSlipFormat, ErrPRArtifactExtractFailed, f.Name)
481+
}
482+
474483
// Sanitize path to prevent Zip Slip attacks.
475484
destPath, err := sanitizeZipPath(f.Name, cleanDestDir)
476485
if err != nil {
477486
return err
478487
}
479488

480-
// Verify path stays within destination (redundant with sanitizeZipPath but satisfies CodeQL).
481-
if rel, relErr := filepath.Rel(strings.TrimSuffix(cleanDestDir, string(os.PathSeparator)), destPath); relErr != nil || strings.HasPrefix(rel, "..") {
482-
return fmt.Errorf("%w: path escapes destination: %s", ErrPRArtifactExtractFailed, f.Name)
483-
}
484-
485489
// Create parent directories.
486490
if f.FileInfo().IsDir() {
491+
// Guard placed immediately adjacent to the sink (rather than relying solely on
492+
// sanitizeZipPath's earlier check) so CodeQL's go/zipslip query, which only
493+
// credits a containment check that directly guards the sink, recognizes it.
494+
if err := verifyWithinDestDir(destPath, cleanDestDir, f.Name); err != nil {
495+
return err
496+
}
487497
if err := os.MkdirAll(destPath, dirPermissions); err != nil {
488498
return fmt.Errorf("%w: failed to create dir: %w", ErrPRArtifactExtractFailed, err)
489499
}
490500
continue
491501
}
492502

493-
if err := os.MkdirAll(filepath.Dir(destPath), dirPermissions); err != nil {
503+
parentDir := filepath.Dir(destPath)
504+
if err := verifyWithinDestDir(parentDir, cleanDestDir, f.Name); err != nil {
505+
return err
506+
}
507+
if err := os.MkdirAll(parentDir, dirPermissions); err != nil {
494508
return fmt.Errorf("%w: failed to create parent dir: %w", ErrPRArtifactExtractFailed, err)
495509
}
496510

497511
// Extract file.
498-
if err := extractZipEntry(f, destPath); err != nil {
512+
if err := extractZipEntry(f, destPath, cleanDestDir); err != nil {
499513
return err
500514
}
501515
}
502516

503517
return nil
504518
}
505519

520+
// verifyWithinDestDir re-validates that path is contained within cleanDestDir immediately
521+
// before a filesystem-mutating operation. This duplicates the check sanitizeZipPath already
522+
// performed, deliberately placed adjacent to each sink (MkdirAll/Create) rather than relying
523+
// on a guarantee computed earlier in the caller, since that's what the go/zipslip scanner needs
524+
// to recognize the path as sanitized at the point of use.
525+
func verifyWithinDestDir(path, cleanDestDir, entryName string) error {
526+
rel, err := filepath.Rel(strings.TrimSuffix(cleanDestDir, string(os.PathSeparator)), path)
527+
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
528+
return fmt.Errorf("%w: path escapes destination: %s", ErrPRArtifactExtractFailed, entryName)
529+
}
530+
return nil
531+
}
532+
506533
// sanitizeZipPath validates that a zip entry path is safe and returns the full destination path.
507534
// This prevents Zip Slip attacks where malicious zip files contain paths like "../../../etc/passwd".
508535
func sanitizeZipPath(entryName, cleanDestDir string) (string, error) {
@@ -537,7 +564,14 @@ func sanitizeZipPath(entryName, cleanDestDir string) (string, error) {
537564
}
538565

539566
// extractZipEntry extracts a single ZIP entry to the destination path.
540-
func extractZipEntry(f *zip.File, destPath string) error {
567+
func extractZipEntry(f *zip.File, destPath, cleanDestDir string) error {
568+
// Guard immediately before the sink: extractZipFile already validated destPath, but this
569+
// function must not trust that a caller-computed guarantee still holds without checking it
570+
// again itself (and it's what lets CodeQL's go/zipslip query see the sanitizer at the sink).
571+
if err := verifyWithinDestDir(destPath, cleanDestDir, f.Name); err != nil {
572+
return err
573+
}
574+
541575
rc, err := f.Open()
542576
if err != nil {
543577
return fmt.Errorf("%w: failed to open ZIP entry: %w", ErrPRArtifactExtractFailed, err)

pkg/toolchain/pr_artifact_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,132 @@ func TestExtractZipFile_WithDirectory(t *testing.T) {
451451
assert.Equal(t, "nested content", string(content))
452452
}
453453

454+
func TestExtractZipFile_RawTraversalName(t *testing.T) {
455+
tempDir := t.TempDir()
456+
zipPath := filepath.Join(tempDir, "evil.zip")
457+
extractDir := filepath.Join(tempDir, "extract")
458+
require.NoError(t, os.MkdirAll(extractDir, 0o755))
459+
460+
// Entry name contains ".." directly; the raw substring guard in extractZipFile
461+
// must reject it before sanitizeZipPath is even consulted.
462+
createTestZip(t, zipPath, map[string][]byte{
463+
"../evil.txt": []byte("malicious"),
464+
})
465+
466+
err := extractZipFile(zipPath, extractDir)
467+
require.Error(t, err)
468+
assert.ErrorIs(t, err, ErrPRArtifactExtractFailed)
469+
assert.Contains(t, err.Error(), "Zip Slip")
470+
}
471+
472+
func TestExtractZipFile_ParentDirCreationFails(t *testing.T) {
473+
tempDir := t.TempDir()
474+
zipPath := filepath.Join(tempDir, "test.zip")
475+
extractDir := filepath.Join(tempDir, "extract")
476+
require.NoError(t, os.MkdirAll(extractDir, 0o755))
477+
478+
// Pre-create a regular file where the entry's parent directory needs to go,
479+
// forcing os.MkdirAll(parentDir, ...) to fail with "not a directory".
480+
blockedPath := filepath.Join(extractDir, "blocked")
481+
require.NoError(t, os.WriteFile(blockedPath, []byte("i am a file, not a dir"), 0o644))
482+
483+
createTestZip(t, zipPath, map[string][]byte{
484+
"blocked/file.txt": []byte("content"),
485+
})
486+
487+
err := extractZipFile(zipPath, extractDir)
488+
require.Error(t, err)
489+
assert.ErrorIs(t, err, ErrPRArtifactExtractFailed)
490+
assert.Contains(t, err.Error(), "failed to create parent dir")
491+
}
492+
493+
func TestExtractZipFile_EntryCreationFails(t *testing.T) {
494+
tempDir := t.TempDir()
495+
zipPath := filepath.Join(tempDir, "test.zip")
496+
extractDir := filepath.Join(tempDir, "extract")
497+
require.NoError(t, os.MkdirAll(extractDir, 0o755))
498+
499+
// Pre-create a directory at the exact destination path of the file entry,
500+
// forcing os.Create(destPath) inside extractZipEntry to fail.
501+
collidingPath := filepath.Join(extractDir, "file.txt")
502+
require.NoError(t, os.MkdirAll(collidingPath, 0o755))
503+
504+
createTestZip(t, zipPath, map[string][]byte{
505+
"file.txt": []byte("content"),
506+
})
507+
508+
err := extractZipFile(zipPath, extractDir)
509+
require.Error(t, err)
510+
assert.ErrorIs(t, err, ErrPRArtifactExtractFailed)
511+
}
512+
513+
func TestVerifyWithinDestDir(t *testing.T) {
514+
baseDir := t.TempDir()
515+
cleanDestDir := filepath.Clean(baseDir) + string(os.PathSeparator)
516+
517+
tests := []struct {
518+
name string
519+
path string
520+
wantErr bool
521+
}{
522+
{
523+
name: "path within dest dir",
524+
path: filepath.Join(baseDir, "sub", "file.txt"),
525+
wantErr: false,
526+
},
527+
{
528+
name: "path exactly at dest dir root",
529+
path: filepath.Clean(baseDir),
530+
wantErr: false,
531+
},
532+
{
533+
name: `path is parent of dest dir (rel == "..")`,
534+
path: filepath.Dir(filepath.Clean(baseDir)),
535+
wantErr: true,
536+
},
537+
{
538+
name: `path is sibling of dest dir (rel starts with "../")`,
539+
path: filepath.Join(filepath.Dir(filepath.Clean(baseDir)), "sibling", "file.txt"),
540+
wantErr: true,
541+
},
542+
}
543+
544+
for _, tt := range tests {
545+
t.Run(tt.name, func(t *testing.T) {
546+
err := verifyWithinDestDir(tt.path, cleanDestDir, "entry.txt")
547+
548+
if tt.wantErr {
549+
require.Error(t, err)
550+
assert.ErrorIs(t, err, ErrPRArtifactExtractFailed)
551+
assert.Contains(t, err.Error(), "escapes destination")
552+
return
553+
}
554+
assert.NoError(t, err)
555+
})
556+
}
557+
}
558+
559+
func TestExtractZipEntry_PathEscape(t *testing.T) {
560+
tempDir := t.TempDir()
561+
zipPath := filepath.Join(tempDir, "test.zip")
562+
createTestZip(t, zipPath, map[string][]byte{"file.txt": []byte("content")})
563+
564+
r, err := zip.OpenReader(zipPath)
565+
require.NoError(t, err)
566+
defer r.Close()
567+
require.Len(t, r.File, 1)
568+
569+
// destPath is outside cleanDestDir even though the entry name itself is benign,
570+
// forcing extractZipEntry's own adjacent-to-sink guard to reject it.
571+
cleanDestDir := filepath.Join(tempDir, "extract") + string(os.PathSeparator)
572+
outsidePath := filepath.Join(tempDir, "outside", "file.txt")
573+
574+
err = extractZipEntry(r.File[0], outsidePath, cleanDestDir)
575+
require.Error(t, err)
576+
assert.ErrorIs(t, err, ErrPRArtifactExtractFailed)
577+
assert.Contains(t, err.Error(), "escapes destination")
578+
}
579+
454580
func TestCopyFile_DestDirNotFound(t *testing.T) {
455581
tempDir := t.TempDir()
456582
srcPath := filepath.Join(tempDir, "source.txt")

website/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
"brace-expansion@^1": "1.1.18",
107107
"brace-expansion@^2": "2.1.4",
108108
"dompurify@^3": "^3.4.12",
109-
"fast-uri@^3": "^3.1.4",
109+
"fast-uri@^3": "^3.1.5",
110110
"follow-redirects@^1": "^1.16.0",
111111
"http-proxy-middleware@^2": "^2.0.10",
112112
"joi@^17": "^17.13.4",

0 commit comments

Comments
 (0)