mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-28 23:18:15 +00:00
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:
committed by
GitHub
parent
c800f706e4
commit
66dc30e755
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ package matchers
|
||||
|
||||
import (
|
||||
"github.com/petergtz/pegomock"
|
||||
"reflect"
|
||||
"github.com/runatlantis/atlantis/server/events/webhooks"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
func AnyApplyResult() webhooks.ApplyResult {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -543,6 +543,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
|
||||
userConfig.EnableRegExpCmd,
|
||||
userConfig.AutoplanModulesFromProjects,
|
||||
userConfig.AutoplanFileList,
|
||||
userConfig.RestrictFileList,
|
||||
statsScope,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user