diff --git a/conf/agent/agent.conf b/conf/agent/agent.conf index e09b1a3bcf..7fa63ceb76 100644 --- a/conf/agent/agent.conf +++ b/conf/agent/agent.conf @@ -26,4 +26,8 @@ plugins { plugin_data { } } + WorkloadAttestor "docker" { + plugin_data { + } + } } diff --git a/doc/plugin_agent_workloadattestor_docker.md b/doc/plugin_agent_workloadattestor_docker.md new file mode 100644 index 0000000000..fd39cd44d3 --- /dev/null +++ b/doc/plugin_agent_workloadattestor_docker.md @@ -0,0 +1,37 @@ +# Agent plugin: WorkloadAttestor "docker" + +The `docker` plugin generates selectors based on docker labels for workloads calling the agent. +It does so by retrieving the workload's container ID from its cgroup membership, then querying +the docker daemon for the container's labels. + +| Configuration | Description | +| ------------- | ----------- | +| docker_socket_path | The location of the docker daemon socket (default: "unix:///var/run/docker.sock" on unix). | +| docker_version | The API version of the docker daemon (default: "1.25"). + +Since selectors are created dynamically based on the container's docker labels, there isn't a list of known selectors. +Instead, each of the container's labels are used in creating the list of selectors. + +| Selector | Example | Description | +| ----------------- | ----------------------------------- | ----------------------------------------------------- | +| `docker:label` | `docker:label:com.example.name:foo` | The key:value pair of each of the container's labels. | +| `docker:image_id` | `docker:image_id:77af4d6b9913` | The image id of the container. | + +## Example +### Labels +If a workload container is started with `docker run --label com.example.name=foo [...]`, then workload registration would occur as: +``` +spire-server entry create \ + -parentID spiffe://example.org/host \ + -spiffeID spiffe://example.org/host/foo \ + -selector docker:label:com.example.name:foo +``` + +You can compose multiple labels as selectors. +``` +spire-server entry create \ + -parentID spiffe://example.org/host \ + -spiffeID spiffe://example.org/host/foo \ + -selector docker:label:com.example.name:foo + -selector docker:label:com.example.env:prod +``` diff --git a/go.mod b/go.mod index ee221648a2..72a8c2b4b7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module github.com/spiffe/spire require ( cloud.google.com/go v0.34.0 // indirect github.com/Azure/azure-sdk-for-go v19.1.0+incompatible + github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/Azure/go-autorest v10.15.2+incompatible + github.com/Microsoft/go-winio v0.4.11 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 // indirect github.com/armon/go-metrics v0.0.0-20180713145231-3c58d8115a78 github.com/armon/go-radix v1.0.0 // indirect @@ -11,6 +14,10 @@ require ( github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dimchansky/utfbom v1.0.0 // indirect + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/docker v0.7.3-0.20190123164140-de86ba27fbea + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.3.3 // indirect github.com/envoyproxy/go-control-plane v0.6.6 github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect github.com/go-ini/ini v1.38.2 // indirect @@ -24,6 +31,8 @@ require ( github.com/golang/protobuf v1.2.0 github.com/google/go-cmp v0.2.0 // indirect github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/mux v1.6.2 // indirect github.com/grpc-ecosystem/grpc-gateway v1.4.1 github.com/hashicorp/go-hclog v0.0.0-20180828044259-75ecd6e6d645 github.com/hashicorp/go-immutable-radix v1.0.0 // indirect @@ -42,7 +51,10 @@ require ( github.com/mitchellh/go-testing-interface v1.0.0 // indirect github.com/onsi/ginkgo v1.7.0 // indirect github.com/onsi/gomega v1.4.3 // indirect + github.com/opencontainers/go-digest v1.0.0-rc1 // indirect + github.com/opencontainers/image-spec v1.0.1 // indirect github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c // indirect + github.com/pkg/errors v0.8.1 // indirect github.com/posener/complete v1.1.2 // indirect github.com/shirou/gopsutil v0.0.0-20180801053943-8048a2e9c577 github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4 // indirect @@ -65,4 +77,5 @@ require ( gopkg.in/ini.v1 v1.40.0 // indirect gopkg.in/square/go-jose.v2 v2.1.8 gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 + gotest.tools v2.2.0+incompatible // indirect ) diff --git a/go.sum b/go.sum index 525e1a866f..0ffc06191b 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,14 @@ cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/Azure/azure-sdk-for-go v19.1.0+incompatible h1:ysqLW+tqZjJWOTE74heH/pDRbr4vlN3yV+dqQYgpyxw= github.com/Azure/azure-sdk-for-go v19.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-autorest v10.15.2+incompatible h1:oZpnRzZie83xGV5txbT1aa/7zpCPvURGhV6ThJij2bs= github.com/Azure/go-autorest v10.15.2+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Microsoft/go-winio v0.4.11 h1:zoIOcVf0xPN1tnMVbTtEdI+P8OofVk3NObnwOQ6nK2Q= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 h1:fLjPD/aNc3UIOA6tDi6QXUemppXK3P9BI7mr2hd6gx8= github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= github.com/armon/go-metrics v0.0.0-20180713145231-3c58d8115a78 h1:mdRSArcFLfW0VoL34LZAKSz6LkkK4jFxVx2xYavACMg= @@ -23,6 +29,14 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dimchansky/utfbom v1.0.0 h1:fGC2kkf4qOoKqZ4q7iIh+Vef4ubC1c38UDsEyZynZPc= github.com/dimchansky/utfbom v1.0.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v0.7.3-0.20190123164140-de86ba27fbea h1:Bk0m1+4R5L8Z41SxoQsbLozrhnUTfLONQGyqPcqpY84= +github.com/docker/docker v0.7.3-0.20190123164140-de86ba27fbea/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/envoyproxy/go-control-plane v0.6.6 h1:vIu840n6c17xjQK9NDM3pfU5u1xiAzdbdwTog9V/MlU= github.com/envoyproxy/go-control-plane v0.6.6/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= @@ -54,6 +68,10 @@ github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/grpc-ecosystem/grpc-gateway v1.4.1 h1:pX7cnDwSSmG0dR9yNjCQSSpmsJOqFdT7SzVp5Yl9uVw= github.com/grpc-ecosystem/grpc-gateway v1.4.1/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= @@ -107,8 +125,14 @@ github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= @@ -169,3 +193,5 @@ gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/pkg/agent/catalog/catalog.go b/pkg/agent/catalog/catalog.go index 221f6ba4f9..b794cebbe4 100644 --- a/pkg/agent/catalog/catalog.go +++ b/pkg/agent/catalog/catalog.go @@ -14,6 +14,7 @@ import ( "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/jointoken" k8s_na "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/k8s" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/x509pop" + "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/docker" k8s_wa "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/k8s" "github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/unix" "github.com/spiffe/spire/proto/agent/keymanager" @@ -57,8 +58,9 @@ var ( "k8s_sat": nodeattestor.NewBuiltIn(k8s_na.NewSATAttestorPlugin()), }, WorkloadAttestorType: { - "k8s": workloadattestor.NewBuiltIn(k8s_wa.New()), - "unix": workloadattestor.NewBuiltIn(unix.New()), + "k8s": workloadattestor.NewBuiltIn(k8s_wa.New()), + "unix": workloadattestor.NewBuiltIn(unix.New()), + "docker": workloadattestor.NewBuiltIn(docker.New()), }, } ) diff --git a/pkg/agent/plugin/workloadattestor/docker/docker.go b/pkg/agent/plugin/workloadattestor/docker/docker.go new file mode 100644 index 0000000000..2a4eb9341b --- /dev/null +++ b/pkg/agent/plugin/workloadattestor/docker/docker.go @@ -0,0 +1,161 @@ +package docker + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + dockerclient "github.com/docker/docker/client" + "github.com/hashicorp/hcl" + "github.com/spiffe/spire/pkg/agent/common/cgroups" + "github.com/spiffe/spire/proto/agent/workloadattestor" + "github.com/spiffe/spire/proto/common" + spi "github.com/spiffe/spire/proto/common/plugin" +) + +const ( + selectorType = "docker" + subselectorLabel = "label" + subselectorImageID = "image_id" + defaultCgroupPrefix = "/docker" +) + +var defaultContainerIndex = 1 + +// DockerClient is a subset of the docker client functionality, useful for mocking. +type DockerClient interface { + ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) +} + +type dockerPlugin struct { + docker DockerClient + cgroupPrefix string + cgroupContainerIndex int + fs cgroups.FileSystem + mtx *sync.RWMutex +} + +type dockerPluginConfig struct { + // DockerSocketPath is the location of the docker daemon socket (default: "unix:///var/run/docker.sock" on unix). + DockerSocketPath string `hcl:"docker_socket_path"` + // DockerVersion is the API version of the docker daemon (default: "1.40"). + DockerVersion string `hcl:"docker_version"` + // CgroupPrefix is the cgroup prefix to look for in the cgroup entries (default: "/docker"). + CgroupPrefix string `hcl:"cgroup_prefix"` + // CgroupContainerIndex is the index within the cgroup path where the container ID should be found (default: 1). + // This is a *int to allow differentiation between the default int value (0) and the absence of the field. + CgroupContainerIndex *int `hcl:"cgroup_container_index"` +} + +func (p *dockerPlugin) Attest(ctx context.Context, req *workloadattestor.AttestRequest) (*workloadattestor.AttestResponse, error) { + p.mtx.RLock() + defer p.mtx.RUnlock() + + cgroupList, err := cgroups.GetCgroups(req.Pid, p.fs) + if err != nil { + return nil, err + } + + var containerID string + var hasDockerEntries bool + for _, cgroup := range cgroupList { + // We are only interested in cgroup entries that match our desired prefix. Example entry: + // "10:perf_event:/docker/2235ebefd9babe0dde4df4e7c49708e24fb31fb851edea55c0ee29a18273cdf4" + if !strings.HasPrefix(cgroup.GroupPath, p.cgroupPrefix) { + continue + } + hasDockerEntries = true + + parts := strings.Split(cgroup.GroupPath, "/") + + if len(parts) <= p.cgroupContainerIndex+1 { + log.Printf("Docker entry found, but is missing the container id: %v", cgroup.GroupPath) + continue + } + containerID = parts[p.cgroupContainerIndex+1] + break + } + + // Not a docker workload. Since it is expected that non-docker workloads will call the + // workload API, it is fine to return a response without any selectors. + if !hasDockerEntries { + return &workloadattestor.AttestResponse{}, nil + } + if containerID == "" { + return nil, fmt.Errorf("workloadattestor/docker: no cgroup %q entries found at index %d", p.cgroupPrefix, p.cgroupContainerIndex) + } + + container, err := p.docker.ContainerInspect(ctx, containerID) + if err != nil { + return nil, err + } + + return &workloadattestor.AttestResponse{ + Selectors: getSelectorsFromConfig(container.Config), + }, nil +} + +func getSelectorsFromConfig(cfg *container.Config) []*common.Selector { + var selectors []*common.Selector + for label, value := range cfg.Labels { + selectors = append(selectors, &common.Selector{ + Type: selectorType, + Value: fmt.Sprintf("%s:%s:%s", subselectorLabel, label, value), + }) + } + if cfg.Image != "" { + selectors = append(selectors, &common.Selector{ + Type: selectorType, + Value: fmt.Sprintf("%s:%s", subselectorImageID, cfg.Image), + }) + } + return selectors +} + +func (p *dockerPlugin) Configure(ctx context.Context, req *spi.ConfigureRequest) (*spi.ConfigureResponse, error) { + p.mtx.Lock() + defer p.mtx.Unlock() + + var err error + config := &dockerPluginConfig{} + if err = hcl.Decode(config, req.Configuration); err != nil { + return nil, err + } + + var opts []func(*dockerclient.Client) error + if config.DockerSocketPath != "" { + opts = append(opts, dockerclient.WithHost(config.DockerSocketPath)) + } + if config.DockerVersion != "" { + opts = append(opts, dockerclient.WithVersion(config.DockerVersion)) + } + p.docker, err = dockerclient.NewClientWithOpts(opts...) + if err != nil { + return nil, err + } + if config.CgroupPrefix == "" { + config.CgroupPrefix = defaultCgroupPrefix + } + if config.CgroupContainerIndex == nil { + config.CgroupContainerIndex = &defaultContainerIndex + } + p.cgroupPrefix = config.CgroupPrefix + p.cgroupContainerIndex = *config.CgroupContainerIndex + + return &spi.ConfigureResponse{}, nil +} + +func (*dockerPlugin) GetPluginInfo(context.Context, *spi.GetPluginInfoRequest) (*spi.GetPluginInfoResponse, error) { + return &spi.GetPluginInfoResponse{}, nil +} + +func New() *dockerPlugin { + return &dockerPlugin{ + mtx: &sync.RWMutex{}, + fs: cgroups.OSFileSystem{}, + } +} diff --git a/pkg/agent/plugin/workloadattestor/docker/docker_test.go b/pkg/agent/plugin/workloadattestor/docker/docker_test.go new file mode 100644 index 0000000000..d49303e6f2 --- /dev/null +++ b/pkg/agent/plugin/workloadattestor/docker/docker_test.go @@ -0,0 +1,233 @@ +package docker + +import ( + "context" + "errors" + "io/ioutil" + "os" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + dockerclient "github.com/docker/docker/client" + gomock "github.com/golang/mock/gomock" + "github.com/spiffe/spire/proto/agent/workloadattestor" + spi "github.com/spiffe/spire/proto/common/plugin" + filesystem_mock "github.com/spiffe/spire/test/mock/common/filesystem" + "github.com/stretchr/testify/require" +) + +func TestDockerLabels(t *testing.T) { + tests := []struct { + desc string + mockContainerLabels map[string]string + mockImageID string + requireResult func(*testing.T, *workloadattestor.AttestResponse) + }{ + { + desc: "single label", + mockContainerLabels: map[string]string{"this": "that"}, + requireResult: func(t *testing.T, res *workloadattestor.AttestResponse) { + require.Len(t, res.Selectors, 1) + require.Equal(t, "docker", res.Selectors[0].Type) + require.Equal(t, "label:this:that", res.Selectors[0].Value) + }, + }, + { + desc: "many labels", + mockContainerLabels: map[string]string{"this": "that", "here": "there", "up": "down"}, + requireResult: func(t *testing.T, res *workloadattestor.AttestResponse) { + require.Len(t, res.Selectors, 3) + expectedLabels := map[string]bool{ + "label:this:that": true, + "label:here:there": true, + "label:up:down": true, + } + for _, selector := range res.Selectors { + require.Equal(t, "docker", selector.Type) + require.Contains(t, expectedLabels, selector.Value) + } + }, + }, + { + desc: "no labels for container", + mockContainerLabels: map[string]string{}, + requireResult: func(t *testing.T, res *workloadattestor.AttestResponse) { + require.Len(t, res.Selectors, 0) + }, + }, + { + desc: "image id", + mockImageID: "my-docker-image", + requireResult: func(t *testing.T, res *workloadattestor.AttestResponse) { + require.Len(t, res.Selectors, 1) + require.Equal(t, "docker", res.Selectors[0].Type) + require.Equal(t, "image_id:my-docker-image", res.Selectors[0].Value) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockDocker := NewMockDockerClient(mockCtrl) + mockFS := filesystem_mock.NewMockfileSystem(mockCtrl) + + p := New() + p.docker = mockDocker + p.fs = mockFS + p.cgroupContainerIndex = 1 + p.cgroupPrefix = "/docker" + + cgroupFile, cleanup := newTestFile(t, "10:devices:/docker/6469646e742065787065637420616e796f6e6520746f20726561642074686973") + defer cleanup() + ctx := context.Background() + container := types.ContainerJSON{ + Config: &container.Config{ + Labels: tt.mockContainerLabels, + Image: tt.mockImageID, + }, + } + mockFS.EXPECT().Open("/proc/123/cgroup").Return(os.Open(cgroupFile)) + mockDocker.EXPECT().ContainerInspect(ctx, "6469646e742065787065637420616e796f6e6520746f20726561642074686973").Return(container, nil) + + res, err := p.Attest(ctx, &workloadattestor.AttestRequest{Pid: 123}) + require.NoError(t, err) + require.NotNil(t, res) + tt.requireResult(t, res) + }) + } +} + +func newTestFile(t *testing.T, data string) (filename string, cleanup func()) { + f, err := ioutil.TempFile("", "docker-test") + require.NoError(t, err) + _, err = f.Write([]byte(data)) + require.NoError(t, err) + return f.Name(), func() { os.Remove(f.Name()) } +} + +func TestDockerCgroupFormatErrors(t *testing.T) { + tests := []struct { + desc string + cfgCgroupPrefix string + cfgCgroupContainerIndex int + mockCgroupEntries string + expectErr string + }{ + { + desc: "no container id found at requested index", + cfgCgroupPrefix: "/docker", + cfgCgroupContainerIndex: 2, + mockCgroupEntries: "10:devices:/docker/6469646e742065787065637420616e796f6e6520746f20726561642074686973", + expectErr: `workloadattestor/docker: no cgroup "/docker" entries found at index 2`, + }, + { + desc: "no cgroup prefix found is ok", + cfgCgroupPrefix: "/foo", + cfgCgroupContainerIndex: 2, + mockCgroupEntries: "10:devices:/docker/6469646e742065787065637420616e796f6e6520746f20726561642074686973", + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockFS := filesystem_mock.NewMockfileSystem(mockCtrl) + + p := New() + p.fs = mockFS + p.cgroupContainerIndex = tt.cfgCgroupContainerIndex + p.cgroupPrefix = tt.cfgCgroupPrefix + + cgroupFile, cleanup := newTestFile(t, tt.mockCgroupEntries) + defer cleanup() + ctx := context.Background() + mockFS.EXPECT().Open("/proc/123/cgroup").Return(os.Open(cgroupFile)) + + res, err := p.Attest(ctx, &workloadattestor.AttestRequest{Pid: 123}) + if tt.expectErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectErr) + require.Nil(t, res) + return + } + require.NoError(t, err) + require.NotNil(t, res) + require.Len(t, res.Selectors, 0) + }) + } +} + +func TestCgroupFileNotFound(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockFS := filesystem_mock.NewMockfileSystem(mockCtrl) + + p := New() + p.fs = mockFS + + mockFS.EXPECT().Open("/proc/123/cgroup").Return(nil, errors.New("no proc exists")) + + res, err := p.Attest(context.Background(), &workloadattestor.AttestRequest{Pid: 123}) + require.Error(t, err) + require.Contains(t, err.Error(), "no proc exists") + require.Nil(t, res) +} + +func TestDockerError(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + mockDocker := NewMockDockerClient(mockCtrl) + mockFS := filesystem_mock.NewMockfileSystem(mockCtrl) + + p := New() + p.docker = mockDocker + p.fs = mockFS + + cgroupFile, cleanup := newTestFile(t, "1:foo:/bar") + defer cleanup() + ctx := context.Background() + mockFS.EXPECT().Open("/proc/123/cgroup").Return(os.Open(cgroupFile)) + mockDocker.EXPECT().ContainerInspect(ctx, "bar").Return(types.ContainerJSON{}, errors.New("docker error")) + + res, err := p.Attest(ctx, &workloadattestor.AttestRequest{Pid: 123}) + require.Error(t, err) + require.Contains(t, err.Error(), "docker error") + require.Nil(t, res) +} + +func TestDockerConfig(t *testing.T) { + p := New() + cfg := &spi.ConfigureRequest{ + Configuration: ` +docker_socket_path = "unix:///socket_path" +docker_version = "1.20" +cgroup_prefix = "prefix" +cgroup_container_index = 8 +`, + } + res, err := p.Configure(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, p.docker) + require.Equal(t, "unix:///socket_path", p.docker.(*dockerclient.Client).DaemonHost()) + require.Equal(t, "1.20", p.docker.(*dockerclient.Client).ClientVersion()) + require.Equal(t, "prefix", p.cgroupPrefix) + require.Equal(t, 8, p.cgroupContainerIndex) +} + +func TestDockerConfigDefault(t *testing.T) { + p := New() + cfg := &spi.ConfigureRequest{} + res, err := p.Configure(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, p.docker) + require.Equal(t, dockerclient.DefaultDockerHost, p.docker.(*dockerclient.Client).DaemonHost()) + require.Equal(t, "1.40", p.docker.(*dockerclient.Client).ClientVersion()) + require.Equal(t, "/docker", p.cgroupPrefix) + require.Equal(t, 1, p.cgroupContainerIndex) +} diff --git a/pkg/agent/plugin/workloadattestor/docker/generate.go b/pkg/agent/plugin/workloadattestor/docker/generate.go new file mode 100644 index 0000000000..b3d1d43450 --- /dev/null +++ b/pkg/agent/plugin/workloadattestor/docker/generate.go @@ -0,0 +1,3 @@ +package docker + +//go:generate mockgen -package docker -destination mock_dockerclient.go github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/docker DockerClient diff --git a/pkg/agent/plugin/workloadattestor/docker/mock_dockerclient.go b/pkg/agent/plugin/workloadattestor/docker/mock_dockerclient.go new file mode 100644 index 0000000000..0abc7c2973 --- /dev/null +++ b/pkg/agent/plugin/workloadattestor/docker/mock_dockerclient.go @@ -0,0 +1,48 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/spiffe/spire/pkg/agent/plugin/workloadattestor/docker (interfaces: DockerClient) + +// Package docker is a generated GoMock package. +package docker + +import ( + context "context" + types "github.com/docker/docker/api/types" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockDockerClient is a mock of DockerClient interface +type MockDockerClient struct { + ctrl *gomock.Controller + recorder *MockDockerClientMockRecorder +} + +// MockDockerClientMockRecorder is the mock recorder for MockDockerClient +type MockDockerClientMockRecorder struct { + mock *MockDockerClient +} + +// NewMockDockerClient creates a new mock instance +func NewMockDockerClient(ctrl *gomock.Controller) *MockDockerClient { + mock := &MockDockerClient{ctrl: ctrl} + mock.recorder = &MockDockerClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockDockerClient) EXPECT() *MockDockerClientMockRecorder { + return m.recorder +} + +// ContainerInspect mocks base method +func (m *MockDockerClient) ContainerInspect(arg0 context.Context, arg1 string) (types.ContainerJSON, error) { + ret := m.ctrl.Call(m, "ContainerInspect", arg0, arg1) + ret0, _ := ret[0].(types.ContainerJSON) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ContainerInspect indicates an expected call of ContainerInspect +func (mr *MockDockerClientMockRecorder) ContainerInspect(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerInspect", reflect.TypeOf((*MockDockerClient)(nil).ContainerInspect), arg0, arg1) +}