diff --git a/executor.go b/executor.go index 6ecf910a..50f12a29 100644 --- a/executor.go +++ b/executor.go @@ -34,6 +34,7 @@ type ( Insecure bool Download bool Offline bool + TrustedHosts []string Timeout time.Duration CacheExpiryDuration time.Duration Watch bool @@ -225,6 +226,20 @@ func (o *offlineOption) ApplyToExecutor(e *Executor) { e.Offline = o.offline } +// WithTrustedHosts configures the [Executor] with a list of trusted hosts for remote +// Taskfiles. Hosts in this list will not prompt for user confirmation. +func WithTrustedHosts(trustedHosts []string) ExecutorOption { + return &trustedHostsOption{trustedHosts} +} + +type trustedHostsOption struct { + trustedHosts []string +} + +func (o *trustedHostsOption) ApplyToExecutor(e *Executor) { + e.TrustedHosts = o.trustedHosts +} + // WithTimeout sets the [Executor]'s timeout for fetching remote taskfiles. By // default, the timeout is set to 10 seconds. func WithTimeout(timeout time.Duration) ExecutorOption { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 5787e17a..b358cee4 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -73,6 +73,7 @@ var ( Experiments bool Download bool Offline bool + TrustedHosts []string ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration @@ -152,6 +153,7 @@ func init() { if experiments.RemoteTaskfiles.Enabled() { pflag.BoolVar(&Download, "download", false, "Downloads a cached version of a remote Taskfile.") pflag.BoolVar(&Offline, "offline", getConfig(config, func() *bool { return config.Remote.Offline }, false), "Forces Task to only use local or cached Taskfiles.") + pflag.StringSliceVar(&TrustedHosts, "trusted-hosts", config.Remote.TrustedHosts, "List of trusted hosts for remote Taskfiles (comma-separated).") pflag.DurationVar(&Timeout, "timeout", getConfig(config, func() *time.Duration { return config.Remote.Timeout }, time.Second*10), "Timeout for downloading remote Taskfiles.") pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.") pflag.DurationVar(&CacheExpiryDuration, "expiry", getConfig(config, func() *time.Duration { return config.Remote.CacheExpiry }, 0), "Expiry duration for cached remote Taskfiles.") @@ -238,6 +240,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithInsecure(Insecure), task.WithDownload(Download), task.WithOffline(Offline), + task.WithTrustedHosts(TrustedHosts), task.WithTimeout(Timeout), task.WithCacheExpiryDuration(CacheExpiryDuration), task.WithWatch(Watch), diff --git a/setup.go b/setup.go index 6234ceef..c9038647 100644 --- a/setup.go +++ b/setup.go @@ -84,6 +84,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { taskfile.WithInsecure(e.Insecure), taskfile.WithDownload(e.Download), taskfile.WithOffline(e.Offline), + taskfile.WithTrustedHosts(e.TrustedHosts), taskfile.WithTempDir(e.TempDir.Remote), taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration), taskfile.WithDebugFunc(debugFunc), diff --git a/task_test.go b/task_test.go index cc8098fd..52a147f0 100644 --- a/task_test.go +++ b/task_test.go @@ -9,6 +9,7 @@ import ( rand "math/rand/v2" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "regexp" @@ -786,6 +787,11 @@ func TestIncludesRemote(t *testing.T) { var buff SyncBuffer + // Extract host from server URL for trust testing + parsedURL, err := url.Parse(srv.URL) + require.NoError(t, err) + trustedHost := parsedURL.Host + executors := []struct { name string executor *task.Executor @@ -825,6 +831,23 @@ func TestIncludesRemote(t *testing.T) { task.WithOffline(true), ), }, + { + name: "with trusted hosts, no prompts", + executor: task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTimeout(time.Minute), + task.WithInsecure(true), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithVerbose(true), + + // With trusted hosts + task.WithTrustedHosts([]string{trustedHost}), + task.WithDownload(true), + ), + }, } for _, e := range executors { diff --git a/taskfile/reader.go b/taskfile/reader.go index 402c3f72..7e7bce13 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -3,6 +3,7 @@ package taskfile import ( "context" "fmt" + "net/url" "os" "sync" "time" @@ -43,6 +44,7 @@ type ( insecure bool download bool offline bool + trustedHosts []string tempDir string cacheExpiryDuration time.Duration debugFunc DebugFunc @@ -59,6 +61,7 @@ func NewReader(opts ...ReaderOption) *Reader { insecure: false, download: false, offline: false, + trustedHosts: nil, tempDir: os.TempDir(), cacheExpiryDuration: 0, debugFunc: nil, @@ -119,6 +122,20 @@ func (o *offlineOption) ApplyToReader(r *Reader) { r.offline = o.offline } +// WithTrustedHosts configures the [Reader] with a list of trusted hosts for remote +// Taskfiles. Hosts in this list will not prompt for user confirmation. +func WithTrustedHosts(trustedHosts []string) ReaderOption { + return &trustedHostsOption{trustedHosts: trustedHosts} +} + +type trustedHostsOption struct { + trustedHosts []string +} + +func (o *trustedHostsOption) ApplyToReader(r *Reader) { + r.trustedHosts = o.trustedHosts +} + // WithTempDir sets the temporary directory that will be used by the [Reader]. // By default, the reader uses [os.TempDir]. func WithTempDir(tempDir string) ReaderOption { @@ -206,6 +223,28 @@ func (r *Reader) promptf(format string, a ...any) error { return nil } +// isTrusted checks if a URI's host matches any of the trusted hosts patterns. +func (r *Reader) isTrusted(uri string) bool { + if len(r.trustedHosts) == 0 { + return false + } + + // Parse the URI to extract the host + parsedURL, err := url.Parse(uri) + if err != nil { + return false + } + host := parsedURL.Host + + // Check against each trusted pattern (exact match including port if provided) + for _, pattern := range r.trustedHosts { + if host == pattern { + return true + } + } + return false +} + func (r *Reader) include(ctx context.Context, node Node) error { // Create a new vertex for the Taskfile vertex := &ast.TaskfileVertex{ @@ -459,9 +498,9 @@ func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([] // If there is no manual checksum pin, run the automatic checks if node.Checksum() == "" { - // Prompt the user if required + // Prompt the user if required (unless host is trusted) prompt := cache.ChecksumPrompt(checksum) - if prompt != "" { + if prompt != "" && !r.isTrusted(node.Location()) { if err := func() error { r.promptMutex.Lock() defer r.promptMutex.Unlock() diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 9410ed17..9f601ef1 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -3,6 +3,7 @@ package ast import ( "cmp" "maps" + "slices" "time" "github.com/Masterminds/semver/v3" @@ -17,10 +18,11 @@ type TaskRC struct { } type Remote struct { - Insecure *bool `yaml:"insecure"` - Offline *bool `yaml:"offline"` - Timeout *time.Duration `yaml:"timeout"` - CacheExpiry *time.Duration `yaml:"cache-expiry"` + Insecure *bool `yaml:"insecure"` + Offline *bool `yaml:"offline"` + Timeout *time.Duration `yaml:"timeout"` + CacheExpiry *time.Duration `yaml:"cache-expiry"` + TrustedHosts []string `yaml:"trusted-hosts"` } // Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC. @@ -43,6 +45,12 @@ func (t *TaskRC) Merge(other *TaskRC) { t.Remote.Timeout = cmp.Or(other.Remote.Timeout, t.Remote.Timeout) t.Remote.CacheExpiry = cmp.Or(other.Remote.CacheExpiry, t.Remote.CacheExpiry) + if len(other.Remote.TrustedHosts) > 0 { + merged := slices.Concat(other.Remote.TrustedHosts, t.Remote.TrustedHosts) + slices.Sort(merged) + t.Remote.TrustedHosts = slices.Compact(merged) + } + t.Verbose = cmp.Or(other.Verbose, t.Verbose) t.Concurrency = cmp.Or(other.Concurrency, t.Concurrency) } diff --git a/taskrc/taskrc_test.go b/taskrc/taskrc_test.go index 1db8221a..902f65de 100644 --- a/taskrc/taskrc_test.go +++ b/taskrc/taskrc_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -135,3 +136,174 @@ func TestGetConfig_All(t *testing.T) { //nolint:paralleltest // cannot run in pa }, }, cfg) } + +func TestGetConfig_RemoteTrustedHosts(t *testing.T) { //nolint:paralleltest // cannot run in parallel + _, _, localDir := setupDirs(t) + + // Test with single host + configYAML := ` +remote: + trusted-hosts: + - github.com +` + writeFile(t, localDir, ".taskrc.yml", configYAML) + + cfg, err := GetConfig(localDir) + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, []string{"github.com"}, cfg.Remote.TrustedHosts) + + // Test with multiple hosts + configYAML = ` +remote: + trusted-hosts: + - github.com + - gitlab.com + - example.com:8080 +` + writeFile(t, localDir, ".taskrc.yml", configYAML) + + cfg, err = GetConfig(localDir) + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, []string{"github.com", "gitlab.com", "example.com:8080"}, cfg.Remote.TrustedHosts) +} + +func TestGetConfig_RemoteTrustedHostsMerge(t *testing.T) { //nolint:paralleltest // cannot run in parallel + t.Run("file-based merge precedence", func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel + xdgConfigDir, homeDir, localDir := setupDirs(t) + + // XDG config has github.com and gitlab.com + xdgConfig := ` +remote: + trusted-hosts: + - github.com + - gitlab.com + timeout: "30s" +` + writeFile(t, xdgConfigDir, "taskrc.yml", xdgConfig) + + // Home config has example.com (should be combined with XDG) + homeConfig := ` +remote: + trusted-hosts: + - example.com +` + writeFile(t, homeDir, ".taskrc.yml", homeConfig) + + cfg, err := GetConfig(localDir) + assert.NoError(t, err) + assert.NotNil(t, cfg) + // Home config entries come first, then XDG + assert.Equal(t, []string{"example.com", "github.com", "gitlab.com"}, cfg.Remote.TrustedHosts) + + // Test with local config too + localConfig := ` +remote: + trusted-hosts: + - local.dev +` + writeFile(t, localDir, ".taskrc.yml", localConfig) + + cfg, err = GetConfig(localDir) + assert.NoError(t, err) + assert.NotNil(t, cfg) + // Local config entries come first + assert.Equal(t, []string{"example.com", "github.com", "gitlab.com", "local.dev"}, cfg.Remote.TrustedHosts) + }) + + t.Run("merge edge cases", func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel + tests := []struct { + name string + base *ast.TaskRC + other *ast.TaskRC + expected []string + }{ + { + name: "merge hosts into empty", + base: &ast.TaskRC{}, + other: &ast.TaskRC{ + Remote: ast.Remote{ + TrustedHosts: []string{"github.com"}, + }, + }, + expected: []string{"github.com"}, + }, + { + name: "merge combines lists", + base: &ast.TaskRC{ + Remote: ast.Remote{ + TrustedHosts: []string{"base.com"}, + }, + }, + other: &ast.TaskRC{ + Remote: ast.Remote{ + TrustedHosts: []string{"other.com"}, + }, + }, + expected: []string{"base.com", "other.com"}, + }, + { + name: "merge empty list does not override", + base: &ast.TaskRC{ + Remote: ast.Remote{ + TrustedHosts: []string{"base.com"}, + }, + }, + other: &ast.TaskRC{ + Remote: ast.Remote{ + TrustedHosts: []string{}, + }, + }, + expected: []string{"base.com"}, + }, + { + name: "merge nil does not override", + base: &ast.TaskRC{ + Remote: ast.Remote{ + TrustedHosts: []string{"base.com"}, + }, + }, + other: &ast.TaskRC{ + Remote: ast.Remote{ + TrustedHosts: nil, + }, + }, + expected: []string{"base.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel + tt.base.Merge(tt.other) + assert.Equal(t, tt.expected, tt.base.Remote.TrustedHosts) + }) + } + }) + + t.Run("all remote fields merge", func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel + insecureTrue := true + offlineTrue := true + timeout := 30 * time.Second + cacheExpiry := 1 * time.Hour + + base := &ast.TaskRC{} + other := &ast.TaskRC{ + Remote: ast.Remote{ + Insecure: &insecureTrue, + Offline: &offlineTrue, + Timeout: &timeout, + CacheExpiry: &cacheExpiry, + TrustedHosts: []string{"github.com", "gitlab.com"}, + }, + } + + base.Merge(other) + + assert.Equal(t, &insecureTrue, base.Remote.Insecure) + assert.Equal(t, &offlineTrue, base.Remote.Offline) + assert.Equal(t, &timeout, base.Remote.Timeout) + assert.Equal(t, &cacheExpiry, base.Remote.CacheExpiry) + assert.Equal(t, []string{"github.com", "gitlab.com"}, base.Remote.TrustedHosts) + }) +} diff --git a/website/src/docs/experiments/remote-taskfiles.md b/website/src/docs/experiments/remote-taskfiles.md index 88a8cc79..066cc855 100644 --- a/website/src/docs/experiments/remote-taskfiles.md +++ b/website/src/docs/experiments/remote-taskfiles.md @@ -214,7 +214,10 @@ remote Taskfiles: Sometimes you need to run Task in an environment that does not have an interactive terminal, so you are not able to accept a prompt. In these cases you are able to tell task to accept these prompts automatically by using the `--yes` -flag. Before enabling this flag, you should: +flag or the `--trust` flag. The `--trust` flag allows you to specify trusted +hosts for remote Taskfiles, while `--yes` applies to all prompts in Task. You +can also configure trusted hosts in your [taskrc configuration](#trusted-hosts) using +`remote.trusted-hosts`. Before enabling automatic trust, you should: 1. Be sure that you trust the source and contents of the remote Taskfile. 2. Consider using a pinned version of the remote Taskfile (e.g. A link @@ -305,6 +308,9 @@ remote: offline: false timeout: "30s" cache-expiry: "24h" + trusted-hosts: + - github.com + - gitlab.com ``` #### `insecure` @@ -353,3 +359,38 @@ remote: remote: cache-expiry: "6h" ``` + +#### `trusted-hosts` + +- **Type**: `array of strings` +- **Default**: `[]` (empty list) +- **Description**: List of trusted hosts for remote Taskfiles. Hosts in this + list will not prompt for confirmation when downloading Taskfiles +- **CLI equivalent**: `--trusted-hosts` + +```yaml +remote: + trusted-hosts: + - github.com + - gitlab.com + - raw.githubusercontent.com + - example.com:8080 +``` + +Hosts in the trusted hosts list will automatically be trusted without prompting for +confirmation when they are first downloaded or when their checksums change. The +host matching includes the port if specified in the URL. Use with caution and +only add hosts you fully trust. + +You can also specify trusted hosts via the command line: + +```shell +# Trust specific host for this execution +task --trusted-hosts github.com -t https://github.com/user/repo.git//Taskfile.yml + +# Trust multiple hosts (comma-separated) +task --trusted-hosts github.com,gitlab.com -t https://github.com/user/repo.git//Taskfile.yml + +# Trust a host with a specific port +task --trusted-hosts example.com:8080 -t https://example.com:8080/Taskfile.yml +``` diff --git a/website/src/public/schema-taskrc.json b/website/src/public/schema-taskrc.json index ac101acf..56281c24 100644 --- a/website/src/public/schema-taskrc.json +++ b/website/src/public/schema-taskrc.json @@ -42,6 +42,13 @@ "type": "string", "description": "Expiry duration for cached remote Taskfiles (e.g., '1h', '24h')", "pattern": "^[0-9]+(ns|us|µs|ms|s|m|h)$" + }, + "trusted-hosts": { + "type": "array", + "description": "List of trusted hosts for remote Taskfiles (e.g., 'github.com', 'gitlab.com', 'example.com:8080').", + "items": { + "type": "string" + } } }, "additionalProperties": false