diff --git a/cmd/entrypoint/README.md b/cmd/entrypoint/README.md index b7f783940cd..199a0930ae4 100644 --- a/cmd/entrypoint/README.md +++ b/cmd/entrypoint/README.md @@ -30,6 +30,12 @@ The following flags are available: same value as `{{stdout_path}}` so both streams are copied to the same file. However, there is no ordering guarantee on data copied from both streams. +- `-enable_spire`: If set will enable signing of the results by SPIRE. Signing + results by SPIRE ensures that no process other than the current process can + tamper the results and go undetected. +- `-spire_socket_path`: This flag makes sense only when enable_spire is set. + When enable_spire is set, spire_socket_path is used to point to the + SPIRE agent socket for SPIFFE workload API. Any extra positional arguments are passed to the original entrypoint command. diff --git a/cmd/entrypoint/main.go b/cmd/entrypoint/main.go index 445452f7464..f138c46e2e6 100644 --- a/cmd/entrypoint/main.go +++ b/cmd/entrypoint/main.go @@ -34,6 +34,8 @@ import ( "github.com/tektoncd/pipeline/pkg/credentials/dockercreds" "github.com/tektoncd/pipeline/pkg/credentials/gitcreds" "github.com/tektoncd/pipeline/pkg/entrypoint" + "github.com/tektoncd/pipeline/pkg/spire" + "github.com/tektoncd/pipeline/pkg/spire/config" "github.com/tektoncd/pipeline/pkg/termination" ) @@ -51,6 +53,8 @@ var ( onError = flag.String("on_error", "", "Set to \"continue\" to ignore an error and continue when a container terminates with a non-zero exit code."+ " Set to \"stopAndFail\" to declare a failure with a step error and stop executing the rest of the steps.") stepMetadataDir = flag.String("step_metadata_dir", "", "If specified, create directory to store the step metadata e.g. /tekton/steps//") + enableSpire = flag.Bool("enable_spire", false, "If specified by configmap, this enables spire signing and verification") + socketPath = flag.String("spire_socket_path", "unix:///spiffe-workload-api/spire-agent.sock", "Experimental: The SPIRE agent socket for SPIFFE workload API.") ) const ( @@ -131,6 +135,14 @@ func main() { } } + var spireWorkloadAPI spire.EntrypointerAPIClient + if enableSpire != nil && *enableSpire && socketPath != nil && *socketPath != "" { + spireConfig := config.SpireConfig{ + SocketPath: *socketPath, + } + spireWorkloadAPI = spire.NewEntrypointerAPIClient(&spireConfig) + } + e := entrypoint.Entrypointer{ Command: append(cmd, commandArgs...), WaitFiles: strings.Split(*waitFiles, ","), @@ -148,6 +160,7 @@ func main() { BreakpointOnFailure: *breakpointOnFailure, OnError: *onError, StepMetadataDir: *stepMetadataDir, + SpireWorkloadAPI: spireWorkloadAPI, } // Copy any creds injected by the controller into the $HOME directory of the current diff --git a/pkg/entrypoint/entrypointer.go b/pkg/entrypoint/entrypointer.go index 92a13219a21..5fc8c59f429 100644 --- a/pkg/entrypoint/entrypointer.go +++ b/pkg/entrypoint/entrypointer.go @@ -31,6 +31,7 @@ import ( "github.com/tektoncd/pipeline/pkg/apis/pipeline" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "github.com/tektoncd/pipeline/pkg/spire" "github.com/tektoncd/pipeline/pkg/termination" "go.uber.org/zap" ) @@ -80,6 +81,10 @@ type Entrypointer struct { OnError string // StepMetadataDir is the directory for a step where the step related metadata can be stored StepMetadataDir string + // SpireWorkloadAPI connects to spire and does obtains SVID based on taskrun + SpireWorkloadAPI spire.EntrypointerAPIClient + // ResultsDirectory is the directory to find results, defaults to pipeline.DefaultResultPath + ResultsDirectory string } // Waiter encapsulates waiting for files to exist. @@ -136,13 +141,14 @@ func (e Entrypointer) Go() error { ResultType: v1beta1.InternalTektonResultType, }) + ctx := context.Background() var err error + if e.Timeout != nil && *e.Timeout < time.Duration(0) { err = fmt.Errorf("negative timeout specified") } if err == nil { - ctx := context.Background() var cancel context.CancelFunc if e.Timeout != nil && *e.Timeout != time.Duration(0) { ctx, cancel = context.WithTimeout(ctx, *e.Timeout) @@ -184,7 +190,11 @@ func (e Entrypointer) Go() error { // strings.Split(..) with an empty string returns an array that contains one element, an empty string. // This creates an error when trying to open the result folder as a file. if len(e.Results) >= 1 && e.Results[0] != "" { - if err := e.readResultsFromDisk(pipeline.DefaultResultPath); err != nil { + resultPath := pipeline.DefaultResultPath + if e.ResultsDirectory != "" { + resultPath = e.ResultsDirectory + } + if err := e.readResultsFromDisk(ctx, resultPath); err != nil { logger.Fatalf("Error while handling results: %s", err) } } @@ -192,7 +202,7 @@ func (e Entrypointer) Go() error { return err } -func (e Entrypointer) readResultsFromDisk(resultDir string) error { +func (e Entrypointer) readResultsFromDisk(ctx context.Context, resultDir string) error { output := []v1beta1.PipelineResourceResult{} for _, resultFile := range e.Results { if resultFile == "" { @@ -211,6 +221,15 @@ func (e Entrypointer) readResultsFromDisk(resultDir string) error { ResultType: v1beta1.TaskRunResultType, }) } + + if e.SpireWorkloadAPI != nil { + signed, err := e.SpireWorkloadAPI.Sign(ctx, output) + if err != nil { + return err + } + output = append(output, signed...) + } + // push output to termination path if len(output) != 0 { if err := termination.WriteMessage(e.TerminationPath, output); err != nil { diff --git a/pkg/entrypoint/entrypointer_test.go b/pkg/entrypoint/entrypointer_test.go index b9784a298be..fe69776755f 100644 --- a/pkg/entrypoint/entrypointer_test.go +++ b/pkg/entrypoint/entrypointer_test.go @@ -24,6 +24,7 @@ import ( "io/ioutil" "os" "os/exec" + "path" "path/filepath" "reflect" "testing" @@ -31,8 +32,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "github.com/tektoncd/pipeline/pkg/spire" "github.com/tektoncd/pipeline/pkg/termination" "github.com/tektoncd/pipeline/test/diff" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "knative.dev/pkg/logging" ) @@ -284,6 +287,7 @@ func TestReadResultsFromDisk(t *testing.T) { }, } { t.Run(c.desc, func(t *testing.T) { + ctx := context.Background() terminationPath := "termination" if terminationFile, err := ioutil.TempFile("", "termination"); err != nil { t.Fatalf("unexpected error creating temporary termination file: %v", err) @@ -314,7 +318,7 @@ func TestReadResultsFromDisk(t *testing.T) { Results: resultsFilePath, TerminationPath: terminationPath, } - if err := e.readResultsFromDisk(""); err != nil { + if err := e.readResultsFromDisk(ctx, ""); err != nil { t.Fatal(err) } msg, err := ioutil.ReadFile(terminationPath) @@ -434,6 +438,167 @@ func TestEntrypointer_OnError(t *testing.T) { } } +func TestEntrypointerResults(t *testing.T) { + for _, c := range []struct { + desc, entrypoint, postFile, stepDir, stepDirLink string + waitFiles, args []string + resultsToWrite map[string]string + resultsOverride []string + breakpointOnFailure bool + sign bool + signVerify bool + }{{ + desc: "do nothing", + }, { + desc: "no results", + entrypoint: "echo", + }, { + desc: "write single result", + entrypoint: "echo", + resultsToWrite: map[string]string{ + "foo": "abc", + }, + }, { + desc: "write multiple result", + entrypoint: "echo", + resultsToWrite: map[string]string{ + "foo": "abc", + "bar": "def", + }, + }, { + // These next two tests show that if not results are defined in the entrypointer, then no signature is produced + // indicating that no signature was created. However, it is important to note that results were defined, + // but no results were created, that signature is still produced. + desc: "no results signed", + entrypoint: "echo", + sign: true, + signVerify: false, + }, { + desc: "defined results but no results produced signed", + entrypoint: "echo", + resultsOverride: []string{"foo"}, + sign: true, + signVerify: true, + }, { + desc: "write single result", + entrypoint: "echo", + resultsToWrite: map[string]string{ + "foo": "abc", + }, + sign: true, + signVerify: true, + }, { + desc: "write multiple result", + entrypoint: "echo", + resultsToWrite: map[string]string{ + "foo": "abc", + "bar": "def", + }, + sign: true, + signVerify: true, + }, { + desc: "write n/m results", + entrypoint: "echo", + resultsToWrite: map[string]string{ + "foo": "abc", + }, + resultsOverride: []string{"foo", "bar"}, + sign: true, + signVerify: true, + }} { + t.Run(c.desc, func(t *testing.T) { + ctx := context.Background() + fw, fpw := &fakeWaiter{}, &fakePostWriter{} + var fr Runner = &fakeRunner{} + timeout := time.Duration(0) + terminationPath := "termination" + if terminationFile, err := ioutil.TempFile("", "termination"); err != nil { + t.Fatalf("unexpected error creating temporary termination file: %v", err) + } else { + terminationPath = terminationFile.Name() + defer os.Remove(terminationFile.Name()) + } + + resultsDir := createTmpDir(t, "results") + var results []string + if c.resultsToWrite != nil { + tmpResultsToWrite := map[string]string{} + for k, v := range c.resultsToWrite { + resultFile := path.Join(resultsDir, k) + tmpResultsToWrite[resultFile] = v + results = append(results, k) + } + + fr = &fakeResultsWriter{ + resultsToWrite: tmpResultsToWrite, + } + } + + signClient, verifyClient, tr := getMockSpireClient(ctx) + if !c.sign { + signClient = nil + } + + if c.resultsOverride != nil { + results = c.resultsOverride + } + + err := Entrypointer{ + Command: append([]string{c.entrypoint}, c.args...), + WaitFiles: c.waitFiles, + PostFile: c.postFile, + Waiter: fw, + Runner: fr, + PostWriter: fpw, + Results: results, + ResultsDirectory: resultsDir, + TerminationPath: terminationPath, + Timeout: &timeout, + BreakpointOnFailure: c.breakpointOnFailure, + StepMetadataDir: c.stepDir, + SpireWorkloadAPI: signClient, + }.Go() + if err != nil { + t.Fatalf("Entrypointer failed: %v", err) + } + + fileContents, err := ioutil.ReadFile(terminationPath) + if err == nil { + resultCheck := map[string]bool{} + var entries []v1beta1.PipelineResourceResult + if err := json.Unmarshal(fileContents, &entries); err != nil { + t.Fatalf("failed to unmarshal results: %v", err) + } + + for _, result := range entries { + if _, ok := c.resultsToWrite[result.Key]; ok { + if c.resultsToWrite[result.Key] == result.Value { + resultCheck[result.Key] = true + } else { + t.Errorf("expected result (%v) to have value %v, got %v", result.Key, result.Value, c.resultsToWrite[result.Key]) + } + } + } + + if len(resultCheck) != len(c.resultsToWrite) { + t.Error("number of results matching did not add up") + } + + // Check signature + verified := verifyClient.VerifyTaskRunResults(ctx, entries, tr) == nil + if verified != c.signVerify { + t.Errorf("expected signature verify result %v, got %v", c.signVerify, verified) + } + } else if !os.IsNotExist(err) { + t.Error("Wanted termination file written, got nil") + } + if err := os.Remove(terminationPath); err != nil { + t.Errorf("Could not remove termination path: %s", err) + } + }) + } +} + type fakeWaiter struct{ waited []string } func (f *fakeWaiter) Wait(file string, _ bool, _ bool) error { @@ -503,3 +668,55 @@ func (f *fakeExitErrorRunner) Run(ctx context.Context, args ...string) error { f.args = &args return exec.Command("ls", "/bogus/path").Run() } + +type fakeResultsWriter struct { + args *[]string + resultsToWrite map[string]string +} + +func (f *fakeResultsWriter) Run(ctx context.Context, args ...string) error { + f.args = &args + for k, v := range f.resultsToWrite { + err := ioutil.WriteFile(k, []byte(v), 0666) + if err != nil { + return err + } + } + return nil +} + +func createTmpDir(t *testing.T, name string) string { + tmpDir, err := ioutil.TempDir("", name) + if err != nil { + t.Fatalf("unexpected error creating temporary dir: %v", err) + } + return tmpDir +} + +func getMockSpireClient(ctx context.Context) (spire.EntrypointerAPIClient, spire.ControllerAPIClient, *v1beta1.TaskRun) { + tr := &v1beta1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "taskrun-example", + Namespace: "foo", + }, + Spec: v1beta1.TaskRunSpec{ + TaskRef: &v1beta1.TaskRef{ + Name: "taskname", + APIVersion: "a1", + }, + ServiceAccountName: "test-sa", + }, + } + + sc := &spire.MockClient{} + + _ = sc.CreateEntries(ctx, tr, nil, 10000) + + // bootstrap with about 20 calls to sign which should be enough for testing + id := sc.GetIdentity(tr) + for i := 0; i < 20; i++ { + sc.SignIdentities = append(sc.SignIdentities, id) + } + + return sc, sc, tr +}