Repository: docker/compose
Pull request #12610 (2025-03-06 15:03:46)

add warning message when a remote configuration include an another remote config
**What I did**
If users start a OCI Compose application which includes remote resources, ask the users to confirm before starting it. 
Display links (oci and git) of the remote configurations to the users

**Related issue**
https://docker.atlassian.net/browse/APCLI-882

**(not mandatory) A picture of a cute animal, if possible in relation to what you did**
![image](https://github.com/user-attachments/assets/da12dd7b-3547-45c6-b4a1-986dbbcec326)



Patch:
diff --git a/cmd/compose/options.go b/cmd/compose/options.go
index c71ee167f..3b6ba1beb 100644
--- a/cmd/compose/options.go
+++ b/cmd/compose/options.go
@@ -29,6 +29,7 @@ import (
 	"github.com/compose-spec/compose-go/v2/template"
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/docker/cli/cli/command"
+	"github.com/docker/compose/v2/internal/tracing"
 	ui "github.com/docker/compose/v2/pkg/progress"
 	"github.com/docker/compose/v2/pkg/prompt"
 	"github.com/docker/compose/v2/pkg/utils"
@@ -103,6 +104,11 @@ func checksForRemoteStack(ctx context.Context, dockerCli command.Cli, project *t
 	if !isRemoteConfig(dockerCli, options) {
 		return nil
 	}
+	if metrics, ok := ctx.Value(tracing.MetricsKey{}).(tracing.Metrics); ok && metrics.CountIncludesRemote > 0 {
+		if err := confirmRemoteIncludes(dockerCli, options, assumeYes); err != nil {
+			return err
+		}
+	}
 	displayLocationRemoteStack(dockerCli, project, options)
 	return promptForInterpolatedVariables(ctx, dockerCli, options.ProjectOptions, assumeYes, cmdEnvs)
 }
@@ -245,3 +251,41 @@ func displayLocationRemoteStack(dockerCli command.Cli, project *types.Project, o
 		_, _ = fmt.Fprintf(dockerCli.Out(), "Your compose stack %q is stored in %q\n", mainComposeFile, project.WorkingDir)
 	}
 }
+
+func confirmRemoteIncludes(dockerCli command.Cli, options buildOptions, assumeYes bool) error {
+	if assumeYes {
+		return nil
+	}
+
+	var remoteIncludes []string
+	remoteLoaders := options.ProjectOptions.remoteLoaders(dockerCli)
+	for _, cf := range options.ProjectOptions.ConfigPaths {
+		for _, loader := range remoteLoaders {
+			if loader.Accept(cf) {
+				remoteIncludes = append(remoteIncludes, cf)
+				break
+			}
+		}
+	}
+
+	if len(remoteIncludes) == 0 {
+		return nil
+	}
+
+	_, _ = fmt.Fprintln(dockerCli.Out(), "\nWarning: This Compose project includes files from remote sources:")
+	for _, include := range remoteIncludes {
+		_, _ = fmt.Fprintf(dockerCli.Out(), "  - %s\n", include)
+	}
+	_, _ = fmt.Fprintln(dockerCli.Out(), "\nRemote includes could potentially be malicious. Make sure you trust the source.")
+
+	msg := "Do you want to continue? [y/N]: "
+	confirmed, err := prompt.NewPrompt(dockerCli.In(), dockerCli.Out()).Confirm(msg, false)
+	if err != nil {
+		return err
+	}
+	if !confirmed {
+		return fmt.Errorf("operation cancelled by user")
+	}
+
+	return nil
+}
diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go
index 6153893f8..07dbbcb42 100644
--- a/pkg/prompt/prompt.go
+++ b/pkg/prompt/prompt.go
@@ -96,6 +96,6 @@ type Pipe struct {
 func (u Pipe) Confirm(message string, defaultValue bool) (bool, error) {
 	_, _ = fmt.Fprint(u.stdout, message)
 	var answer string
-	_, _ = fmt.Scanln(&answer)
+	_, _ = fmt.Fscanln(u.stdin, &answer)
 	return utils.StringToBool(answer), nil
 }


Test patch:
diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go
index 3e1700b96..035d1820e 100644
--- a/cmd/compose/options_test.go
+++ b/cmd/compose/options_test.go
@@ -20,6 +20,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"strings"
@@ -280,3 +281,114 @@ services:
 		normalizeSpaces(actualOutput),
 		"\nExpected:\n%s\nGot:\n%s", expected, actualOutput)
 }
+
+func TestConfirmRemoteIncludes(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	cli := mocks.NewMockCli(ctrl)
+
+	tests := []struct {
+		name       string
+		opts       buildOptions
+		assumeYes  bool
+		userInput  string
+		wantErr    bool
+		errMessage string
+		wantPrompt bool
+		wantOutput string
+	}{
+		{
+			name: "no remote includes",
+			opts: buildOptions{
+				ProjectOptions: &ProjectOptions{
+					ConfigPaths: []string{
+						"docker-compose.yaml",
+						"./local/path/compose.yaml",
+					},
+				},
+			},
+			assumeYes:  false,
+			wantErr:    false,
+			wantPrompt: false,
+		},
+		{
+			name: "assume yes with remote includes",
+			opts: buildOptions{
+				ProjectOptions: &ProjectOptions{
+					ConfigPaths: []string{
+						"oci://registry.example.com/stack:latest",
+						"git://github.com/user/repo.git",
+					},
+				},
+			},
+			assumeYes:  true,
+			wantErr:    false,
+			wantPrompt: false,
+		},
+		{
+			name: "user confirms remote includes",
+			opts: buildOptions{
+				ProjectOptions: &ProjectOptions{
+					ConfigPaths: []string{
+						"oci://registry.example.com/stack:latest",
+						"git://github.com/user/repo.git",
+					},
+				},
+			},
+			assumeYes:  false,
+			userInput:  "y\n",
+			wantErr:    false,
+			wantPrompt: true,
+			wantOutput: "\nWarning: This Compose project includes files from remote sources:\n" +
+				"  - oci://registry.example.com/stack:latest\n" +
+				"  - git://github.com/user/repo.git\n" +
+				"\nRemote includes could potentially be malicious. Make sure you trust the source.\n" +
+				"Do you want to continue? [y/N]: ",
+		},
+		{
+			name: "user rejects remote includes",
+			opts: buildOptions{
+				ProjectOptions: &ProjectOptions{
+					ConfigPaths: []string{
+						"oci://registry.example.com/stack:latest",
+					},
+				},
+			},
+			assumeYes:  false,
+			userInput:  "n\n",
+			wantErr:    true,
+			errMessage: "operation cancelled by user",
+			wantPrompt: true,
+			wantOutput: "\nWarning: This Compose project includes files from remote sources:\n" +
+				"  - oci://registry.example.com/stack:latest\n" +
+				"\nRemote includes could potentially be malicious. Make sure you trust the source.\n" +
+				"Do you want to continue? [y/N]: ",
+		},
+	}
+
+	buf := new(bytes.Buffer)
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cli.EXPECT().Out().Return(streams.NewOut(buf)).AnyTimes()
+
+			if tt.wantPrompt {
+				inbuf := io.NopCloser(bytes.NewBufferString(tt.userInput))
+				cli.EXPECT().In().Return(streams.NewIn(inbuf)).AnyTimes()
+			}
+
+			err := confirmRemoteIncludes(cli, tt.opts, tt.assumeYes)
+
+			if tt.wantErr {
+				require.Error(t, err)
+				require.Equal(t, tt.errMessage, err.Error())
+			} else {
+				require.NoError(t, err)
+			}
+
+			if tt.wantOutput != "" {
+				require.Equal(t, tt.wantOutput, buf.String())
+			}
+			buf.Reset()
+		})
+	}
+}