Skip to content

feat: checksum pinning #2223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 2100-redacted-url-credentials
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (
CodeTaskfileNetworkTimeout
CodeTaskfileInvalid
CodeTaskfileCycle
CodeTaskfileDoesNotMatchChecksum
)

// Task related exit codes
Expand Down
21 changes: 21 additions & 0 deletions errors/errors_taskfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,24 @@ func (err TaskfileCycleError) Error() string {
func (err TaskfileCycleError) Code() int {
return CodeTaskfileCycle
}

// TaskfileDoesNotMatchChecksum is returned when a Taskfile's checksum does not
// match the one pinned in the parent Taskfile.
type TaskfileDoesNotMatchChecksum struct {
URI string
ExpectedChecksum string
ActualChecksum string
}

func (err *TaskfileDoesNotMatchChecksum) Error() string {
return fmt.Sprintf(
"task: The checksum of the Taskfile at %q does not match!\ngot: %q\nwant: %q",
err.URI,
err.ActualChecksum,
err.ExpectedChecksum,
)
}

func (err *TaskfileDoesNotMatchChecksum) Code() int {
return CodeTaskfileDoesNotMatchChecksum
}
20 changes: 20 additions & 0 deletions executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -958,3 +958,23 @@ func TestFuzzyModel(t *testing.T) {
WithTask("install"),
)
}

func TestIncludeChecksum(t *testing.T) {
t.Parallel()

NewExecutorTest(t,
WithName("correct"),
WithExecutorOptions(
task.WithDir("testdata/includes_checksum/correct"),
),
)

NewExecutorTest(t,
WithName("incorrect"),
WithExecutorOptions(
task.WithDir("testdata/includes_checksum/incorrect"),
),
WithSetupError(),
WithPostProcessFn(PPRemoveAbsolutePaths),
)
}
5 changes: 5 additions & 0 deletions taskfile/ast/include.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type (
AdvancedImport bool
Vars *Vars
Flatten bool
Checksum string
}
// Includes is an ordered map of namespaces to includes.
Includes struct {
Expand Down Expand Up @@ -165,6 +166,7 @@ func (include *Include) UnmarshalYAML(node *yaml.Node) error {
Aliases []string
Excludes []string
Vars *Vars
Checksum string
}
if err := node.Decode(&includedTaskfile); err != nil {
return errors.NewTaskfileDecodeError(err, node)
Expand All @@ -178,6 +180,7 @@ func (include *Include) UnmarshalYAML(node *yaml.Node) error {
include.AdvancedImport = true
include.Vars = includedTaskfile.Vars
include.Flatten = includedTaskfile.Flatten
include.Checksum = includedTaskfile.Checksum
return nil
}

Expand All @@ -200,5 +203,7 @@ func (include *Include) DeepCopy() *Include {
AdvancedImport: include.AdvancedImport,
Vars: include.Vars.DeepCopy(),
Flatten: include.Flatten,
Aliases: deepcopy.Slice(include.Aliases),
Checksum: include.Checksum,
}
}
2 changes: 2 additions & 0 deletions taskfile/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type Node interface {
Parent() Node
Location() string
Dir() string
Checksum() string
Verify(checksum string) bool
ResolveEntrypoint(entrypoint string) (string, error)
ResolveDir(dir string) (string, error)
}
Expand Down
19 changes: 17 additions & 2 deletions taskfile/node_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ type (
// designed to be embedded in other node types so that this boilerplate code
// does not need to be repeated.
BaseNode struct {
parent Node
dir string
parent Node
dir string
checksum string
}
)

Expand All @@ -32,10 +33,24 @@ func WithParent(parent Node) NodeOption {
}
}

func WithChecksum(checksum string) NodeOption {
return func(node *BaseNode) {
node.checksum = checksum
}
}

func (node *BaseNode) Parent() Node {
return node.parent
}

func (node *BaseNode) Dir() string {
return node.dir
}

func (node *BaseNode) Checksum() string {
return node.checksum
}

func (node *BaseNode) Verify(checksum string) bool {
return node.checksum == "" || node.checksum == checksum
}
53 changes: 42 additions & 11 deletions taskfile/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ func (r *Reader) include(ctx context.Context, node Node) error {
AdvancedImport: include.AdvancedImport,
Excludes: include.Excludes,
Vars: include.Vars,
Checksum: include.Checksum,
}
if err := cache.Err(); err != nil {
return err
Expand All @@ -267,6 +268,7 @@ func (r *Reader) include(ctx context.Context, node Node) error {

includeNode, err := NewNode(entrypoint, include.Dir, r.insecure,
WithParent(node),
WithChecksum(include.Checksum),
)
if err != nil {
if include.Optional {
Expand Down Expand Up @@ -362,7 +364,24 @@ func (r *Reader) readNodeContent(ctx context.Context, node Node) ([]byte, error)
if node, isRemote := node.(RemoteNode); isRemote {
return r.readRemoteNodeContent(ctx, node)
}
return node.Read()

// Read the Taskfile
b, err := node.Read()
if err != nil {
return nil, err
}

// If the given checksum doesn't match the sum pinned in the Taskfile
checksum := checksum(b)
if !node.Verify(checksum) {
return nil, &errors.TaskfileDoesNotMatchChecksum{
URI: node.Location(),
ExpectedChecksum: node.Checksum(),
ActualChecksum: checksum,
}
}

return b, nil
}

func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([]byte, error) {
Expand Down Expand Up @@ -427,17 +446,29 @@ func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([]
}

r.debugf("found remote file at %q\n", node.Location())

// If the given checksum doesn't match the sum pinned in the Taskfile
checksum := checksum(downloadedBytes)
prompt := cache.ChecksumPrompt(checksum)

// Prompt the user if required
if prompt != "" {
if err := func() error {
r.promptMutex.Lock()
defer r.promptMutex.Unlock()
return r.promptf(prompt, node.Location())
}(); err != nil {
return nil, &errors.TaskfileNotTrustedError{URI: node.Location()}
if !node.Verify(checksum) {
return nil, &errors.TaskfileDoesNotMatchChecksum{
URI: node.Location(),
ExpectedChecksum: node.Checksum(),
ActualChecksum: checksum,
}
}

// If there is no manual checksum pin, run the automatic checks
if node.Checksum() == "" {
// Prompt the user if required
prompt := cache.ChecksumPrompt(checksum)
if prompt != "" {
if err := func() error {
r.promptMutex.Lock()
defer r.promptMutex.Unlock()
return r.promptf(prompt, node.Location())
}(); err != nil {
return nil, &errors.TaskfileNotTrustedError{URI: node.Location()}
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions testdata/includes_checksum/correct/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: '3'

includes:
included:
taskfile: ../included.yml
internal: true
checksum: c97f39eb96fe3fa5fe2a610d244b8449897b06f0c93821484af02e0999781bf5

tasks:
default:
cmds:
- task: included:default
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
task: [included:default] echo "Hello, World!"
Hello, World!
12 changes: 12 additions & 0 deletions testdata/includes_checksum/correct_remote/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: '3'

includes:
included:
taskfile: https://taskfile.dev
internal: true
checksum: c153e97e0b3a998a7ed2e61064c6ddaddd0de0c525feefd6bba8569827d8efe9

tasks:
default:
cmds:
- task: included:default
6 changes: 6 additions & 0 deletions testdata/includes_checksum/included.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: '3'

tasks:
default:
cmds:
- echo "Hello, World!"
12 changes: 12 additions & 0 deletions testdata/includes_checksum/incorrect/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: '3'

includes:
included:
taskfile: ../included.yml
internal: true
checksum: foo

tasks:
default:
cmds:
- task: included:default
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
task: The checksum of the Taskfile at "/testdata/includes_checksum/included.yml" does not match!
got: "c97f39eb96fe3fa5fe2a610d244b8449897b06f0c93821484af02e0999781bf5"
want: "foo"
38 changes: 36 additions & 2 deletions website/docs/experiments/remote_taskfiles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,11 @@ includes:

## Security

### Automatic checksums

Running commands from sources that you do not control is always a potential
security risk. For this reason, we have added some checks when using remote
Taskfiles:
security risk. For this reason, we have added some automatic checks when using
remote Taskfiles:

1. When running a task from a remote Taskfile for the first time, Task will
print a warning to the console asking you to check that you are sure that you
Expand All @@ -209,6 +211,38 @@ flag. Before enabling this flag, you should:
containing a commit hash) to prevent Task from automatically accepting a
prompt that says a remote Taskfile has changed.

### Manual checksum pinning

Alternatively, if you expect the contents of your remote files to be a constant
value, you can pin the checksum of the included file instead:

```yaml
version: '3'

includes:
included:
taskfile: https://taskfile.dev
checksum: c153e97e0b3a998a7ed2e61064c6ddaddd0de0c525feefd6bba8569827d8efe9
```

This will disable the automatic checksum prompts discussed above. However, if
the checksums do not match, Task will exit immediately with an error. When
setting this up for the first time, you may not know the correct value of the
checksum. There are a couple of ways you can obtain this:

1. Add the include normally without the `checksum` key. The first time you run
the included Taskfile, a `.task/remote` temporary directory is created. Find
the correct set of files for your included Taskfile and open the file that
ends with `.checksum`. You can copy the contents of this file and paste it
into the `checksum` key of your include. This method is safest as it allows
you to inspect the downloaded Taskfile before you pin it.
2. Alternatively, add the include with a temporary random value in the
`checksum` key. When you try to run the Taskfile, you will get an error that
will report the incorrect expected checksum and the actual checksum. You can
copy the actual checksum and replace your temporary random value.

### TLS

Task currently supports both `http` and `https` URLs. However, the `http`
requests will not execute by default unless you run the task with the
`--insecure` flag. This is to protect you from accidentally running a remote
Expand Down
1 change: 1 addition & 0 deletions website/docs/reference/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ toc_max_heading_level: 5
| `internal` | `bool` | `false` | Stops any task in the included Taskfile from being callable on the command line. These commands will also be omitted from the output when used with `--list`. |
| `aliases` | `[]string` | | Alternative names for the namespace of the included Taskfile. |
| `vars` | `map[string]Variable` | | A set of variables to apply to the included Taskfile. |
| `checksum` | `string` | | The checksum of the file you expect to include. If the checksum does not match, the file will not be included. |

:::info

Expand Down
4 changes: 4 additions & 0 deletions website/static/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,10 @@
"vars": {
"description": "A set of variables to apply to the included Taskfile.",
"$ref": "#/definitions/vars"
},
"checksum": {
"description": "The checksum of the file you expect to include. If the checksum does not match, the file will not be included.",
"type": "string"
}
}
}
Expand Down
Loading