diff --git a/cmd/server.go b/cmd/server.go index 98d7a0257b..08648cc809 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -114,6 +114,7 @@ const ( SlackTokenFlag = "slack-token" SSLCertFileFlag = "ssl-cert-file" SSLKeyFileFlag = "ssl-key-file" + RestrictFileList = "restrict-file-list" TFDownloadURLFlag = "tf-download-url" VarFileAllowlistFlag = "var-file-allowlist" VCSStatusName = "vcs-status-name" @@ -496,6 +497,10 @@ var boolFlags = map[string]boolFlag{ description: "Switches on or off the Basic Authentication on the HTTP Middleware interface", defaultValue: DefaultWebBasicAuth, }, + RestrictFileList: { + description: "Block plan requests from projects outside the files modified in the pull request.", + defaultValue: false, + }, WebsocketCheckOrigin: { description: "Enable websocket origin check", defaultValue: false, diff --git a/cmd/server_test.go b/cmd/server_test.go index e2741fc50a..b8cee0e74a 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -103,6 +103,7 @@ var testFlags = map[string]interface{}{ SlackTokenFlag: "slack-token", SSLCertFileFlag: "cert-file", SSLKeyFileFlag: "key-file", + RestrictFileList: false, TFDownloadURLFlag: "https://my-hostname.com", TFEHostnameFlag: "my-hostname", TFELocalExecutionModeFlag: true, diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index 2986ca5d51..0104071dd5 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -378,6 +378,8 @@ and set `--autoplan-modules` to `false`. This will not work with `-d` yet and to use `-p` the repo projects must be defined in the repo `atlantis.yaml` file. + This will bypass `--restrict-file-list` if regex is used, normal commands will stil be blocked if necessary. + ::: warning SECURITY WARNING It's not supposed to be used with `--disable-apply-all`. The command `atlantis apply -p .*` will bypass the restriction and run apply on every projects. @@ -886,6 +888,17 @@ and set `--autoplan-modules` to `false`. ``` File containing x509 private key matching `--ssl-cert-file`. +### `--restrict-file-list` + ```bash + atlantis server --restrict-file-list + # or (recommended) + ATLANTIS_RESTRICT_FILE_LIST=true + ``` + `--restrict-file-list` will block plan requests from projects outside the files modified in the pull request. + This will not block plan requests with regex if using the `--enable-regexp-cmd` flag, in these cases commands + like `atlantis plan -p .*` will still work if used. normal commands will stil be blocked if necessary. + Defaults to `false`. + ### `--stats-namespace` ```bash atlantis server --stats-namespace="myatlantis" diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go index b9a2ebf867..1f9b0176d7 100644 --- a/server/controllers/events/events_controller_e2e_test.go +++ b/server/controllers/events/events_controller_e2e_test.go @@ -975,6 +975,7 @@ func setupE2E(t *testing.T, repoDir string) (events_controllers.VCSEventsControl false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, statsScope, logger, ) diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 2d23513232..63c4c44049 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -3,7 +3,9 @@ package events import ( "fmt" "os" + "path/filepath" "sort" + "strings" "github.com/uber-go/tally" @@ -48,6 +50,7 @@ func NewInstrumentedProjectCommandBuilder( EnableRegExpCmd bool, AutoDetectModuleFiles string, AutoplanFileList string, + RestrictFileList bool, scope tally.Scope, logger logging.SimpleLogging, ) *InstrumentedProjectCommandBuilder { @@ -66,6 +69,7 @@ func NewInstrumentedProjectCommandBuilder( EnableRegExpCmd, AutoDetectModuleFiles, AutoplanFileList, + RestrictFileList, scope, logger, ), @@ -87,6 +91,7 @@ func NewProjectCommandBuilder( EnableRegExpCmd bool, AutoDetectModuleFiles string, AutoplanFileList string, + RestrictFileList bool, scope tally.Scope, logger logging.SimpleLogging, ) *DefaultProjectCommandBuilder { @@ -102,6 +107,7 @@ func NewProjectCommandBuilder( EnableRegExpCmd: EnableRegExpCmd, AutoDetectModuleFiles: AutoDetectModuleFiles, AutoplanFileList: AutoplanFileList, + RestrictFileList: RestrictFileList, ProjectCommandContextBuilder: NewProjectCommandContextBuilder( policyChecksSupported, commentBuilder, @@ -166,6 +172,7 @@ type DefaultProjectCommandBuilder struct { AutoDetectModuleFiles string AutoplanFileList string EnableDiffMarkdownFormat bool + RestrictFileList bool } // See ProjectCommandBuilder.BuildAutoplanCommands. @@ -372,6 +379,64 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *command.Cont } var pcc []command.ProjectContext + + // use the default repository workspace because it is the only one guaranteed to have an atlantis.yaml, + // other workspaces will not have the file if they are using pre_workflow_hooks to generate it dynamically + defaultRepoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, DefaultWorkspace) + if err != nil { + return pcc, err + } + + if p.RestrictFileList { + modifiedFiles, err := p.VCSClient.GetModifiedFiles(ctx.Pull.BaseRepo, ctx.Pull) + if err != nil { + return nil, err + } + + if cmd.RepoRelDir != "" { + foundDir := false + + for _, f := range modifiedFiles { + if filepath.Dir(f) == cmd.RepoRelDir { + foundDir = true + } + } + + if !foundDir { + return pcc, fmt.Errorf("the dir \"%s\" is not in the plan list of this pull request", cmd.RepoRelDir) + } + } + + if cmd.ProjectName != "" { + var notFoundFiles = []string{} + var repoConfig valid.RepoCfg + + repoConfig, err = p.ParserValidator.ParseRepoCfg(defaultRepoDir, p.GlobalCfg, ctx.Pull.BaseRepo.ID(), ctx.Pull.BaseBranch) + if err != nil { + return pcc, err + } + repoCfgProjects := repoConfig.FindProjectsByName(cmd.ProjectName) + + for _, f := range modifiedFiles { + foundDir := false + + for _, p := range repoCfgProjects { + if filepath.Dir(f) == p.Dir { + foundDir = true + } + } + + if !foundDir { + notFoundFiles = append(notFoundFiles, filepath.Dir(f)) + } + } + + if len(notFoundFiles) > 0 { + return pcc, fmt.Errorf("the following directories are present in the pull request but not in the requested project:\n%s", strings.Join(notFoundFiles, "\n")) + } + } + } + ctx.Log.Debug("building plan command") unlockFn, err := p.WorkingDirLocker.TryLock(ctx.Pull.BaseRepo.FullName, ctx.Pull.Num, workspace, DefaultRepoRelDir) if err != nil { @@ -390,13 +455,6 @@ func (p *DefaultProjectCommandBuilder) buildProjectPlanCommand(ctx *command.Cont repoRelDir = cmd.RepoRelDir } - // use the default repository workspace because it is the only one guaranteed to have an atlantis.yaml, - // other workspaces will not have the file if they are using pre_workflow_hooks to generate it dynamically - defaultRepoDir, err := p.WorkingDir.GetWorkingDir(ctx.Pull.BaseRepo, ctx.Pull, DefaultWorkspace) - if err != nil { - return pcc, err - } - return p.buildProjectCommandCtx( ctx, command.Plan, diff --git a/server/events/project_command_builder_internal_test.go b/server/events/project_command_builder_internal_test.go index e8b3b29d79..0ffb961c9e 100644 --- a/server/events/project_command_builder_internal_test.go +++ b/server/events/project_command_builder_internal_test.go @@ -627,6 +627,7 @@ projects: false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, statsScope, logger, ) @@ -829,6 +830,7 @@ projects: true, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, statsScope, logger, ) @@ -1059,6 +1061,7 @@ workflows: false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, statsScope, logger, ) diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index a79c592788..b29c630938 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -160,6 +160,7 @@ projects: false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -428,6 +429,7 @@ projects: true, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -462,6 +464,166 @@ projects: } } +// Test building a plan and apply command for one project +// with the RestrictFileList +func TestDefaultProjectCommandBuilder_BuildSinglePlanApplyCommand_WithRestrictFileList(t *testing.T) { + cases := []struct { + Description string + AtlantisYAML string + DirectoryStructure map[string]interface{} + ModifiedFiles []string + Cmd events.CommentCommand + ExpErr string + }{ + { + Description: "planning a file outside of the changed files", + Cmd: events.CommentCommand{ + Name: command.Plan, + RepoRelDir: "directory-1", + Workspace: "default", + }, + DirectoryStructure: map[string]interface{}{ + "directory-1": map[string]interface{}{ + "main.tf": nil, + }, + "directory-2": map[string]interface{}{ + "main.tf": nil, + }, + }, + ModifiedFiles: []string{"directory-2/main.tf"}, + ExpErr: "the dir \"directory-1\" is not in the plan list of this pull request", + }, + { + Description: "planning a file of the changed files", + Cmd: events.CommentCommand{ + Name: command.Plan, + RepoRelDir: "directory-1", + Workspace: "default", + }, + DirectoryStructure: map[string]interface{}{ + "directory-1": map[string]interface{}{ + "main.tf": nil, + }, + "directory-2": map[string]interface{}{ + "main.tf": nil, + }, + }, + ModifiedFiles: []string{"directory-1/main.tf"}, + }, + { + Description: "planning a project outside of the requested changed files", + Cmd: events.CommentCommand{ + Name: command.Plan, + Workspace: "default", + ProjectName: "project-1", + }, + AtlantisYAML: ` +version: 3 +projects: +- name: project-1 + dir: directory-1 +- name: project-2 + dir: directory-2 +`, + DirectoryStructure: map[string]interface{}{ + "directory-1": map[string]interface{}{ + "main.tf": nil, + }, + "directory-2": map[string]interface{}{ + "main.tf": nil, + }, + }, + ModifiedFiles: []string{"directory-2/main.tf"}, + ExpErr: "the following directories are present in the pull request but not in the requested project:\ndirectory-2", + }, + { + Description: "planning a project defined in the requested changed files", + Cmd: events.CommentCommand{ + Name: command.Plan, + Workspace: "default", + ProjectName: "project-1", + }, + AtlantisYAML: ` +version: 3 +projects: +- name: project-1 + dir: directory-1 +- name: project-2 + dir: directory-2 +`, + DirectoryStructure: map[string]interface{}{ + "directory-1": map[string]interface{}{ + "main.tf": nil, + }, + "directory-2": map[string]interface{}{ + "main.tf": nil, + }, + }, + ModifiedFiles: []string{"directory-1/main.tf"}, + }, + } + + logger := logging.NewNoopLogger(t) + scope, _, _ := metrics.NewLoggingScope(logger, "atlantis") + + for _, c := range cases { + t.Run(c.Description+"_"+command.Plan.String(), func(t *testing.T) { + RegisterMockTestingT(t) + tmpDir := DirStructure(t, c.DirectoryStructure) + + workingDir := mocks.NewMockWorkingDir() + When(workingDir.Clone(matchers.AnyPtrToLoggingSimpleLogger(), matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), AnyString())).ThenReturn(tmpDir, false, nil) + When(workingDir.GetWorkingDir(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest(), AnyString())).ThenReturn(tmpDir, nil) + vcsClient := vcsmocks.NewMockClient() + When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn(c.ModifiedFiles, nil) + if c.AtlantisYAML != "" { + err := os.WriteFile(filepath.Join(tmpDir, config.AtlantisYAMLFilename), []byte(c.AtlantisYAML), 0600) + Ok(t, err) + } + + globalCfgArgs := valid.GlobalCfgArgs{ + AllowRepoCfg: true, + MergeableReq: false, + ApprovedReq: false, + UnDivergedReq: false, + } + + builder := events.NewProjectCommandBuilder( + false, + &config.ParserValidator{}, + &events.DefaultProjectFinder{}, + vcsClient, + workingDir, + events.NewDefaultWorkingDirLocker(), + valid.NewGlobalCfgFromArgs(globalCfgArgs), + &events.DefaultPendingPlanFinder{}, + &events.CommentParser{}, + false, + true, + "", + "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + true, + scope, + logger, + ) + + var actCtxs []command.ProjectContext + var err error + actCtxs, err = builder.BuildPlanCommands(&command.Context{ + Log: logger, + Scope: scope, + }, &c.Cmd) + + if c.ExpErr != "" { + ErrEquals(t, c.ExpErr, err) + return + } + Ok(t, err) + Equals(t, 1, len(actCtxs)) + }) + } +} + func TestDefaultProjectCommandBuilder_BuildPlanCommands(t *testing.T) { // expCtxFields define the ctx fields we're going to assert on. // Since we're focused on autoplanning here, we don't validate all the @@ -610,6 +772,7 @@ projects: false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -701,6 +864,7 @@ func TestDefaultProjectCommandBuilder_BuildMultiApply(t *testing.T) { false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -786,6 +950,7 @@ projects: false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -865,6 +1030,7 @@ func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) { false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -1069,6 +1235,7 @@ projects: false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -1158,6 +1325,7 @@ parallel_plan: true`, false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -1217,6 +1385,7 @@ func TestDefaultProjectCommandBuilder_WithPolicyCheckEnabled_BuildAutoplanComman false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) @@ -1299,6 +1468,7 @@ func TestDefaultProjectCommandBuilder_BuildVersionCommand(t *testing.T) { false, "", "**/*.tf,**/*.tfvars,**/*.tfvars.json,**/terragrunt.hcl,**/.terraform.lock.hcl", + false, scope, logger, ) diff --git a/server/events/webhooks/mocks/matchers/applyresult.go b/server/events/webhooks/mocks/matchers/applyresult.go index abe805e2c3..406f0853e3 100644 --- a/server/events/webhooks/mocks/matchers/applyresult.go +++ b/server/events/webhooks/mocks/matchers/applyresult.go @@ -3,8 +3,8 @@ package matchers import ( "github.com/petergtz/pegomock" - "reflect" "github.com/runatlantis/atlantis/server/events/webhooks" + "reflect" ) func AnyApplyResult() webhooks.ApplyResult { diff --git a/server/events/webhooks/mocks/mock_slack_client.go b/server/events/webhooks/mocks/mock_slack_client.go index 1e96692ede..a2b1cd10e4 100644 --- a/server/events/webhooks/mocks/mock_slack_client.go +++ b/server/events/webhooks/mocks/mock_slack_client.go @@ -5,9 +5,9 @@ package mocks import ( pegomock "github.com/petergtz/pegomock" + webhooks "github.com/runatlantis/atlantis/server/events/webhooks" "reflect" "time" - webhooks "github.com/runatlantis/atlantis/server/events/webhooks" ) type MockSlackClient struct { diff --git a/server/events/webhooks/mocks/mock_underlying_slack_client.go b/server/events/webhooks/mocks/mock_underlying_slack_client.go index 27dfa46e4a..8da0ba4025 100644 --- a/server/events/webhooks/mocks/mock_underlying_slack_client.go +++ b/server/events/webhooks/mocks/mock_underlying_slack_client.go @@ -7,8 +7,8 @@ import ( "reflect" "time" - slack "github.com/slack-go/slack" pegomock "github.com/petergtz/pegomock" + slack "github.com/slack-go/slack" ) type MockUnderlyingSlackClient struct { diff --git a/server/server.go b/server/server.go index fa2eff1638..ef91327a75 100644 --- a/server/server.go +++ b/server/server.go @@ -543,6 +543,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { userConfig.EnableRegExpCmd, userConfig.AutoplanModulesFromProjects, userConfig.AutoplanFileList, + userConfig.RestrictFileList, statsScope, logger, ) diff --git a/server/user_config.go b/server/user_config.go index a635bd0593..3b379455a5 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -96,6 +96,7 @@ type UserConfig struct { SlackToken string `mapstructure:"slack-token"` SSLCertFile string `mapstructure:"ssl-cert-file"` SSLKeyFile string `mapstructure:"ssl-key-file"` + RestrictFileList bool `mapstructure:"restrict-file-list"` TFDownloadURL string `mapstructure:"tf-download-url"` TFEHostname string `mapstructure:"tfe-hostname"` TFELocalExecutionMode bool `mapstructure:"tfe-local-execution-mode"`