mirror of
https://github.com/go-task/task.git
synced 2025-12-14 18:57:43 +01:00
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:
15
executor.go
15
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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
1
setup.go
1
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),
|
||||
|
||||
23
task_test.go
23
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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user