Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix bug atmos vendor pull URI cannot contain path traversal sequences and git schema #899

Merged
merged 13 commits into from
Jan 22, 2025
40 changes: 40 additions & 0 deletions examples/tests/test-vendor/atmos.yaml
haitham911 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Consider using explicit component selection instead of --everything flag

The --everything flag in the test command will pull all defined components:

  • github/stargazers (demo, github tags)
  • test-components (demo tag)
  • weather (demo tag)
  • my-vpc1 (no tags)

Instead of using --everything, consider one of these more explicit approaches:

  • Use --tags demo to pull all demo components
  • List specific components: atmos vendor pull -c github/stargazers -c weather
  • Create a separate test command for each component group you need to test

This provides better control over which components are pulled during testing and makes the dependencies more explicit.

🔗 Analysis chain

Review security implications of the --everything flag.

The --everything flag in the test command could potentially pull all vendor components without discrimination. Consider being more explicit about which components to test.

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check what components would be pulled with --everything flag
atmos vendor pull --dry-run --everything

Length of output: 138


Script:

#!/bin/bash
# Let's check the vendor configuration and related files
fd vendor -t f

# Check for any documentation or configuration related to vendor components
rg -i "vendor" -g "*.{md,yaml,yml}" -A 3 -B 3

Length of output: 34075


Script:

#!/bin/bash
# Let's check the vendor.yaml file in the test-vendor directory to understand the configuration
cat examples/tests/test-vendor/vendor.yaml

# Let's also check if there are any specific components or tags defined
rg "component:|tags:" examples/tests/test-vendor/vendor.yaml -A 2

Length of output: 2092

🧰 Tools
🪛 yamllint (1.35.1)

[warning] 37-37: wrong indentation: expected 2 but found 0

(indentation)


[warning] 40-40: wrong indentation: expected 4 but found 2

(indentation)

13 changes: 13 additions & 0 deletions examples/tests/test-vendor/test-components/README.md
Original file line number Diff line number Diff line change
@@ -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

haitham911 marked this conversation as resolved.
Show resolved Hide resolved
### 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.
7 changes: 7 additions & 0 deletions examples/tests/test-vendor/test-components/main.tf
Original file line number Diff line number Diff line change
@@ -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"
}
}
osterman marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 4 additions & 0 deletions examples/tests/test-vendor/test-components/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "metadata" {
description = "The data retrieved from IPinfo for the specified IP address"
value = jsondecode(data.http.ipinfo.response_body)
}
1 change: 1 addition & 0 deletions examples/tests/test-vendor/test-components/providers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
provider "http" {}
5 changes: 5 additions & 0 deletions examples/tests/test-vendor/test-components/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
variable "ip_address" {
description = "The IP address to retrieve information for (optional)"
type = string
default = ""
}
5 changes: 5 additions & 0 deletions examples/tests/test-vendor/test-components/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
terraform {
required_version = ">= 1.0.0"

required_providers {}
}
43 changes: 43 additions & 0 deletions examples/tests/test-vendor/vendor.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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"
osterman marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 9 additions & 0 deletions internal/exec/vendor_component_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/exec/vendor_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
33 changes: 31 additions & 2 deletions internal/exec/vendor_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package exec

import (
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -384,7 +385,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
Expand Down Expand Up @@ -514,12 +515,22 @@ func determineSourceType(uri *string, vendorConfigFilePath string) (bool, bool,
useLocalFileSystem := false
sourceIsLocalFile := false
if !useOciScheme {
if absPath, err := u.JoinAbsolutePathWithPath(vendorConfigFilePath, *uri); err == nil {
if absPath, err := u.JoinAbsolutePathWithPath(filepath.ToSlash(vendorConfigFilePath), *uri); err == nil {
uri = &absPath
useLocalFileSystem = true
sourceIsLocalFile = u.FileExists(*uri)
}
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
}
}

haitham911 marked this conversation as resolved.
Show resolved Hide resolved
}

return useOciScheme, useLocalFileSystem, sourceIsLocalFile
}

Expand Down Expand Up @@ -556,6 +567,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)

Expand Down Expand Up @@ -608,3 +621,19 @@ func generateSkipFunction(atmosConfig schema.AtmosConfiguration, tempDir string,
return false, nil
}
}

func validateURI(uri string) error {
if uri == "" {
return fmt.Errorf("URI cannot be empty")
}
if strings.Contains(uri, " ") {
return fmt.Errorf("URI cannot contain spaces")
}
// Validate scheme-specific format
if strings.HasPrefix(uri, "oci://") {
if !strings.Contains(uri[6:], "/") {
return fmt.Errorf("invalid OCI URI format")
}
}
return nil
}
haitham911 marked this conversation as resolved.
Show resolved Hide resolved
99 changes: 94 additions & 5 deletions tests/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath" // For resolving absolute paths
Expand Down Expand Up @@ -84,7 +86,13 @@ func (m *MatchPattern) UnmarshalYAML(value *yaml.Node) error {
}

func loadTestSuite(filePath string) (*TestSuite, error) {
data, err := os.ReadFile(filePath)
// Open the file
f, err := os.Open(filePath)
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -449,6 +457,76 @@ func TestCLICommands(t *testing.T) {
// Run with `t.Run` for non-TTY tests
t.Run(tc.Name, func(t *testing.T) {
runCLICommandTest(t, tc)
defer func() {
// Change back to the original working directory after the test
if err := os.Chdir(startingDir); err != nil {
t.Fatalf("Failed to change back to the starting directory: %v", err)
}
}()

// Change to the specified working directory
if tc.Workdir != "" {
err := os.Chdir(tc.Workdir)
if err != nil {
t.Fatalf("Failed to change directory to %q: %v", tc.Workdir, err)
}
}

// Check if the binary exists
binaryPath, err := exec.LookPath(tc.Command)
if err != nil {
t.Fatalf("Binary not found: %s. Current PATH: %s", tc.Command, pathManager.GetPath())
}

// Prepare the command
cmd := exec.Command(binaryPath, tc.Args...)

// Set environment variables
envVars := os.Environ()
for key, value := range tc.Env {
envVars = append(envVars, fmt.Sprintf("%s=%s", key, value))
}
cmd.Env = envVars

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

// Run the command
err = cmd.Run()

// Validate exit code
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
}
if exitCode != tc.Expect.ExitCode {
t.Errorf("Description: %s", tc.Description)
t.Errorf("Reason: Expected exit code %d, got %d", tc.Expect.ExitCode, exitCode)
t.Errorf("error: %v", cmd.Stderr)
}

// Validate stdout
if !verifyOutput(t, "stdout", stdout.String(), tc.Expect.Stdout) {
t.Errorf("Description: %s", tc.Description)
}

// Validate stderr
if !verifyOutput(t, "stderr", stderr.String(), tc.Expect.Stderr) {
t.Errorf("Description: %s", tc.Description)
}

// Validate file existence
if !verifyFileExists(t, tc.Expect.FileExists) {
t.Errorf("Description: %s", tc.Description)
}

// Validate file contents
if !verifyFileContains(t, tc.Expect.FileContains) {
t.Errorf("Description: %s", tc.Description)
}
})
}
}
Expand Down Expand Up @@ -512,12 +590,17 @@ func verifyOutput(t *testing.T, outputType, output string, patterns []MatchPatte
}
return success
}

func verifyFileExists(t *testing.T, files []string) bool {
success := true
for _, file := range files {
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
t.Errorf("Reason: Expected file does not exist: %q", file)
fileAbs, err := filepath.Abs(file)
if err != nil {
log.Println(err)
return false
}

if _, err := os.Stat(fileAbs); errors.Is(err, os.ErrNotExist) {
t.Errorf("Reason: Expected file does not exist: %q", fileAbs)
success = false
}
}
Expand All @@ -527,7 +610,13 @@ func verifyFileExists(t *testing.T, files []string) bool {
func verifyFileContains(t *testing.T, filePatterns map[string][]MatchPattern) bool {
success := true
for file, patterns := range filePatterns {
content, err := os.ReadFile(file)
// Open the file
f, err := os.Open(file)
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
t.Errorf("Reason: Failed to read file %q: %v", file, err)
success = false
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/scenarios/complete/vendor.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
Expand Down
Loading
Loading