feat: prevent planning and applying directories outside PR scope using --restrict-file-list (#2440)

* Add --strict-plan-file-list config

* Update server.go

* Update server-configuration.md

* Update server_test.go

* Run gofmt -w

* Add --strict-plan-file-list for projects

* Add --strict-plan-file-list tests

* Change --strict-plan-file-list to --restrict-file-list

* Update --restrict-file-list documentation

* Update --restrict-file-list and --enable-regexp-cmd documentation

Co-authored-by: Fabiano Honorato <fabiano.honorato@ifood.com.br>
Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com>
Co-authored-by: PePe Amengual <jose.amengual@gmail.com>
This commit is contained in:
Fabiano Soares Honorato
2022-12-09 12:33:40 -03:00
committed by GitHub
parent c800f706e4
commit 66dc30e755
12 changed files with 263 additions and 10 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -3,8 +3,8 @@ package matchers
import (
"github.com/petergtz/pegomock"
"reflect"
"github.com/runatlantis/atlantis/server/events/webhooks"
"reflect"
)
func AnyApplyResult() webhooks.ApplyResult {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -543,6 +543,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
userConfig.EnableRegExpCmd,
userConfig.AutoplanModulesFromProjects,
userConfig.AutoplanFileList,
userConfig.RestrictFileList,
statsScope,
logger,
)

View File

@@ -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"`