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/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() + }) + } +} 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 }