feat: add --trusted-hosts CLI and remote.trusted-hosts config for remote tasks (#2491)

Co-authored-by: Valentin Maerten <maerten.valentin@gmail.com>
This commit is contained in:
Maciej Lech
2025-12-07 12:17:54 +01:00
committed by GitHub
parent 896d65b21f
commit 72a349b0e9
9 changed files with 316 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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