diff --git a/internal/exec/vendor_component_utils.go b/internal/exec/vendor_component_utils.go index 5ca0f4c38..94088bdf1 100644 --- a/internal/exec/vendor_component_utils.go +++ b/internal/exec/vendor_component_utils.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -248,6 +249,14 @@ func ExecuteComponentVendorInternal( sourceIsLocalFile = true } } + u, err := url.Parse(uri) + if err == nil && u.Scheme != "" { + if u.Scheme == "file" { + trimmedPath := strings.TrimPrefix(filepath.ToSlash(u.Path), "/") + uri = filepath.Clean(trimmedPath) + useLocalFileSystem = true + } + } } var pType pkgType diff --git a/internal/exec/vendor_model.go b/internal/exec/vendor_model.go index 20e873f24..6ddf14e71 100644 --- a/internal/exec/vendor_model.go +++ b/internal/exec/vendor_model.go @@ -248,7 +248,7 @@ func downloadAndInstall(p *pkgAtmosVendor, dryRun bool, atmosConfig schema.Atmos } } // Create temp directory - tempDir, err := os.MkdirTemp("", fmt.Sprintf("atmos-vendor-%d-*", time.Now().Unix())) + tempDir, err := os.MkdirTemp("", "atmos-vendor") if err != nil { return installedPkgMsg{ err: fmt.Errorf("failed to create temp directory: %w", err), diff --git a/internal/exec/vendor_utils.go b/internal/exec/vendor_utils.go index ee6c6520b..aa6a80c26 100644 --- a/internal/exec/vendor_utils.go +++ b/internal/exec/vendor_utils.go @@ -2,6 +2,7 @@ package exec import ( "fmt" + "net/url" "os" "path/filepath" "sort" @@ -361,12 +362,20 @@ func ExecuteAtmosVendorInternal( if err != nil { return err } - err = ValidateURI(uri) + + useOciScheme, useLocalFileSystem, sourceIsLocalFile, err := determineSourceType(&uri, vendorConfigFilePath) if err != nil { return err } - - useOciScheme, useLocalFileSystem, sourceIsLocalFile := determineSourceType(&uri, vendorConfigFilePath) + if !useLocalFileSystem { + err = ValidateURI(uri) + if err != nil { + if strings.Contains(uri, "..") { + return fmt.Errorf("Invalid URI '%s': %w. Please ensure the source is a valid local path or a properly formatted URI.", uri, err) + } + return err + } + } // Determine package type var pType pkgType @@ -384,7 +393,7 @@ func ExecuteAtmosVendorInternal( if err != nil { return err } - targetPath := filepath.Join(vendorConfigFilePath, target) + targetPath := filepath.Join(filepath.ToSlash(vendorConfigFilePath), filepath.ToSlash(target)) pkgName := s.Component if pkgName == "" { pkgName = uri @@ -504,7 +513,7 @@ func shouldSkipSource(s *schema.AtmosVendorSource, component string, tags []stri return (component != "" && s.Component != component) || (len(tags) > 0 && len(lo.Intersect(tags, s.Tags)) == 0) } -func determineSourceType(uri *string, vendorConfigFilePath string) (bool, bool, bool) { +func determineSourceType(uri *string, vendorConfigFilePath string) (bool, bool, bool, error) { // Determine if the URI is an OCI scheme, a local file, or remote useOciScheme := strings.HasPrefix(*uri, "oci://") if useOciScheme { @@ -514,13 +523,32 @@ func determineSourceType(uri *string, vendorConfigFilePath string) (bool, bool, useLocalFileSystem := false sourceIsLocalFile := false if !useOciScheme { - if absPath, err := u.JoinAbsolutePathWithPath(vendorConfigFilePath, *uri); err == nil { + absPath, err := u.JoinAbsolutePathWithPath(filepath.ToSlash(vendorConfigFilePath), *uri) + // if URI contain path traversal is path should be resolved + if err != nil && strings.Contains(*uri, "..") { + return useOciScheme, useLocalFileSystem, sourceIsLocalFile, fmt.Errorf("invalid source path '%s': %w", *uri, err) + } + if err == nil { uri = &absPath useLocalFileSystem = true sourceIsLocalFile = u.FileExists(*uri) } + + parsedURL, err := url.Parse(*uri) + if err != nil { + return useOciScheme, useLocalFileSystem, sourceIsLocalFile, err + } + if err == nil && parsedURL.Scheme != "" { + if parsedURL.Scheme == "file" { + trimmedPath := strings.TrimPrefix(filepath.ToSlash(parsedURL.Path), "/") + *uri = filepath.Clean(trimmedPath) + useLocalFileSystem = true + } + } + } - return useOciScheme, useLocalFileSystem, sourceIsLocalFile + + return useOciScheme, useLocalFileSystem, sourceIsLocalFile, nil } func copyToTarget(atmosConfig schema.AtmosConfiguration, tempDir, targetPath string, s *schema.AtmosVendorSource, sourceIsLocalFile bool, uri string) error { @@ -556,6 +584,8 @@ func generateSkipFunction(atmosConfig schema.AtmosConfiguration, tempDir string, if filepath.Base(src) == ".git" { return true, nil } + tempDir = filepath.ToSlash(tempDir) + src = filepath.ToSlash(src) trimmedSrc := u.TrimBasePathFromPath(tempDir+"/", src) diff --git a/tests/fixtures/scenarios/complete/vendor.yaml b/tests/fixtures/scenarios/complete/vendor.yaml index 17bba24fc..ab64e00df 100644 --- a/tests/fixtures/scenarios/complete/vendor.yaml +++ b/tests/fixtures/scenarios/complete/vendor.yaml @@ -45,7 +45,7 @@ spec: - test - networking - component: "vpc-flow-logs-bucket" - source: "github.com/cloudposse/terraform-aws-components.git//modules/vpc-flow-logs-bucket?ref={{.Version}}" + source: "git::https://github.com/cloudposse/terraform-aws-components.git//modules/vpc-flow-logs-bucket?ref={{.Version}}" version: "1.323.0" targets: - "components/terraform/infra/vpc-flow-logs-bucket/{{.Version}}" diff --git a/tests/fixtures/scenarios/vendor/atmos.yaml b/tests/fixtures/scenarios/vendor/atmos.yaml new file mode 100644 index 000000000..0f0506e81 --- /dev/null +++ b/tests/fixtures/scenarios/vendor/atmos.yaml @@ -0,0 +1,40 @@ +base_path: "./" + +components: + terraform: + base_path: "components/terraform" + apply_auto_approve: false + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: false + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{stage}" + +vendor: + # Single file + base_path: "./vendor.yaml" + + # Directory with multiple files + #base_path: "./vendor" + + # Absolute path + #base_path: "vendor.d/vendor1.yaml" + +logs: + file: "/dev/stderr" + level: Info + +# Custom CLI commands + +# No arguments or flags are required +commands: +- name: "test" + description: "Run all tests" + steps: + - atmos vendor pull --everything diff --git a/tests/fixtures/scenarios/vendor/test-components/README.md b/tests/fixtures/scenarios/vendor/test-components/README.md new file mode 100644 index 000000000..a1b28ebfd --- /dev/null +++ b/tests/fixtures/scenarios/vendor/test-components/README.md @@ -0,0 +1,13 @@ +# Example Terraform IPinfo Component + +This Terraform module retrieves data from the IPinfo API for a specified IP address. If no IP address is specified, it retrieves data for the requester's IP address. + +## Usage + +### Inputs + +- `ip_address` (optional): The IP address to retrieve information for. If not specified, the requester's IP address will be used. The default value is an empty string. + +### Outputs + +- `metadata`: The data retrieved from IPinfo for the specified IP address, in JSON format. diff --git a/tests/fixtures/scenarios/vendor/test-components/main.tf b/tests/fixtures/scenarios/vendor/test-components/main.tf new file mode 100644 index 000000000..0e3e9d0b0 --- /dev/null +++ b/tests/fixtures/scenarios/vendor/test-components/main.tf @@ -0,0 +1,7 @@ +data "http" "ipinfo" { + url = var.ip_address != "" ? "https://ipinfo.io/${var.ip_address}" : "https://ipinfo.io" + + request_headers = { + Accept = "application/json" + } +} diff --git a/tests/fixtures/scenarios/vendor/test-components/outputs.tf b/tests/fixtures/scenarios/vendor/test-components/outputs.tf new file mode 100644 index 000000000..12bb2bb8d --- /dev/null +++ b/tests/fixtures/scenarios/vendor/test-components/outputs.tf @@ -0,0 +1,4 @@ +output "metadata" { + description = "The data retrieved from IPinfo for the specified IP address" + value = jsondecode(data.http.ipinfo.response_body) +} diff --git a/tests/fixtures/scenarios/vendor/test-components/providers.tf b/tests/fixtures/scenarios/vendor/test-components/providers.tf new file mode 100644 index 000000000..0b91fe2aa --- /dev/null +++ b/tests/fixtures/scenarios/vendor/test-components/providers.tf @@ -0,0 +1 @@ +provider "http" {} diff --git a/tests/fixtures/scenarios/vendor/test-components/variables.tf b/tests/fixtures/scenarios/vendor/test-components/variables.tf new file mode 100644 index 000000000..85f32544d --- /dev/null +++ b/tests/fixtures/scenarios/vendor/test-components/variables.tf @@ -0,0 +1,5 @@ +variable "ip_address" { + description = "The IP address to retrieve information for (optional)" + type = string + default = "" +} diff --git a/tests/fixtures/scenarios/vendor/test-components/versions.tf b/tests/fixtures/scenarios/vendor/test-components/versions.tf new file mode 100644 index 000000000..e2a3d732d --- /dev/null +++ b/tests/fixtures/scenarios/vendor/test-components/versions.tf @@ -0,0 +1,5 @@ +terraform { + required_version = ">= 1.0.0" + + required_providers {} +} diff --git a/tests/fixtures/scenarios/vendor/vendor.yaml b/tests/fixtures/scenarios/vendor/vendor.yaml new file mode 100644 index 000000000..82cf62016 --- /dev/null +++ b/tests/fixtures/scenarios/vendor/vendor.yaml @@ -0,0 +1,53 @@ +apiVersion: atmos/v1 +kind: AtmosVendorConfig +metadata: + name: demo-vendoring + description: Atmos vendoring manifest for Atmos demo component library +spec: + # Import other vendor manifests, if necessary + imports: [] + + sources: + - component: "github/stargazers" + source: "github.com/cloudposse/atmos.git//examples/demo-library/{{ .Component }}?ref={{.Version}}" + version: "main" + targets: + - "components/terraform/{{ .Component }}/{{.Version}}" + included_paths: + - "**/*.tf" + - "**/*.tfvars" + - "**/*.md" + tags: + - demo + - github + + - component: "test-components" + source: "file:///./test-components" + version: "main" + targets: + - "components/terraform/{{ .Component }}/{{.Version}}" + tags: + - demo + + - component: "weather" + source: "git::https://github.com/cloudposse/atmos.git//examples/demo-library/{{ .Component }}?ref={{.Version}}" + version: "main" + targets: + - "components/terraform/{{ .Component }}/{{.Version}}" + tags: + - demo + - component: "my-vpc1" + source: "oci://public.ecr.aws/cloudposse/components/terraform/stable/aws/vpc:{{.Version}}" + version: "latest" + targets: + - "components/terraform/infra/my-vpc1" + - component: "vpc-src" + source: "../../../fixtures/components/terraform/myapp" # required for test URI contain path traversal + targets: + - "components/terraform/vpc-src" + included_paths: + - "**/*.tf" + - "**/*.md" + - "**/*.tftmpl" + - "**/modules/**" + excluded_paths: [] diff --git a/tests/test-cases/demo-stacks.yaml b/tests/test-cases/demo-stacks.yaml index f2c7867cc..80923b7be 100644 --- a/tests/test-cases/demo-stacks.yaml +++ b/tests/test-cases/demo-stacks.yaml @@ -130,3 +130,67 @@ tests: stderr: - "^$" exit_code: 0 + + - name: atmos greet with args + enabled: true + description: "Validate atmos custom command greet runs with argument provided." + workdir: "../examples/demo-custom-command/" + command: "atmos" + args: + - "greet" + - "Andrey" + expect: + stdout: + - "Hello, Andrey\n" + stderr: + - "^$" + exit_code: 0 + + - name: atmos greet without args + enabled: true + description: "Validate atmos custom command greet runs without argument provided." + workdir: "../examples/demo-custom-command/" + command: "atmos" + args: + - "greet" + expect: + stdout: + - "Hello, John Doe\n" + stderr: + - "^$" + exit_code: 0 + - name: atmos_vendor_pull + enabled: true + description: "Ensure atmos vendor pull command executes without errors and files are present." + workdir: "fixtures/scenarios/vendor" + command: "atmos" + args: + - "vendor" + - "pull" + expect: + file_exists: + - "./components/terraform/github/stargazers/main/main.tf" + - "./components/terraform/github/stargazers/main/outputs.tf" + - "./components/terraform/github/stargazers/main/providers.tf" + - "./components/terraform/github/stargazers/main/variables.tf" + - "./components/terraform/github/stargazers/main/versions.tf" + - "./components/terraform/infra/my-vpc1/main.tf" + - "./components/terraform/infra/my-vpc1/outputs.tf" + - "./components/terraform/infra/my-vpc1/providers.tf" + - "./components/terraform/infra/my-vpc1/variables.tf" + - "./components/terraform/infra/my-vpc1/versions.tf" + - "./components/terraform/test-components/main/main.tf" + - "./components/terraform/test-components/main/outputs.tf" + - "./components/terraform/test-components/main/providers.tf" + - "./components/terraform/test-components/main/variables.tf" + - "./components/terraform/test-components/main/versions.tf" + - "./components/terraform/weather/main/main.tf" + - "./components/terraform/weather/main/outputs.tf" + - "./components/terraform/weather/main/providers.tf" + - "./components/terraform/weather/main/variables.tf" + - "./components/terraform/weather/main/versions.tf" + - "./components/terraform/vpc-src/main.tf" + - "./components/terraform/vpc-src/outputs.tf" + - "./components/terraform/vpc-src/variables.tf" + - "./components/terraform/vpc-src/versions.tf" + exit_code: 0