Skip to content
Merged
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
296 changes: 161 additions & 135 deletions api/gen/proto/go/teleport/workloadidentity/v1/attrs.pb.go

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions api/proto/teleport/workloadidentity/v1/attrs.proto
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ message WorkloadAttrsUnix {
uint32 gid = 3;
// The primary group ID of the workload process.
uint32 uid = 4;
// The path to the workload process binary.
optional string binary_path = 5;
// The hex-encoded SHA256 hash of the workload process binary.
optional string binary_hash = 6;
}

// Attributes sourced from the Podman workload attestor.
Expand Down
21 changes: 21 additions & 0 deletions lib/tbot/config/service_spiffe_workload_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,27 @@ func TestSPIFFEWorkloadAPIService_CheckAndSetDefaults(t *testing.T) {
},
}
},
want: &SPIFFEWorkloadAPIService{
JWTSVIDTTL: time.Minute,
Listen: "unix:///var/run/spiffe.sock",
SVIDs: []SVIDRequestWithRules{
{
SVIDRequest: SVIDRequest{
Path: "/foo",
Hint: "hint",
SANS: SVIDRequestSANs{
DNS: []string{"example.com"},
IP: []string{"10.0.0.1", "10.42.0.1"},
},
},
},
},
Attestors: workloadattest.Config{
Unix: workloadattest.UnixAttestorConfig{
BinaryHashMaxSizeBytes: workloadattest.DefaultBinaryHashMaxBytes,
},
},
},
},
{
name: "missing path",
Expand Down
24 changes: 24 additions & 0 deletions lib/tbot/config/service_workload_identity_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ func TestWorkloadIdentityAPIService_CheckAndSetDefaults(t *testing.T) {
Listen: "tcp://0.0.0.0:4040",
}
},
want: &WorkloadIdentityAPIService{
Selector: WorkloadIdentitySelector{
Name: "my-workload-identity",
},
Listen: "tcp://0.0.0.0:4040",
Attestors: workloadattest.Config{
Unix: workloadattest.UnixAttestorConfig{
BinaryHashMaxSizeBytes: workloadattest.DefaultBinaryHashMaxBytes,
},
},
},
},
{
name: "valid with labels",
Expand All @@ -92,6 +103,19 @@ func TestWorkloadIdentityAPIService_CheckAndSetDefaults(t *testing.T) {
Listen: "tcp://0.0.0.0:4040",
}
},
want: &WorkloadIdentityAPIService{
Selector: WorkloadIdentitySelector{
Labels: map[string][]string{
"key": {"value"},
},
},
Listen: "tcp://0.0.0.0:4040",
Attestors: workloadattest.Config{
Unix: workloadattest.UnixAttestorConfig{
BinaryHashMaxSizeBytes: workloadattest.DefaultBinaryHashMaxBytes,
},
},
},
},
{
name: "missing selectors",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ services:
enabled: false
systemd:
enabled: false
unix: {}
credential_ttl: 30s
renewal_interval: 15s
- type: example
Expand Down Expand Up @@ -93,6 +94,7 @@ services:
enabled: false
systemd:
enabled: false
unix: {}
selector:
name: my-workload-identity
credential_ttl: 30s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ attestors:
enabled: false
systemd:
enabled: false
unix: {}
jwt_svid_ttl: 5m0s
credential_ttl: 1m0s
renewal_interval: 30s
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ attestors:
enabled: false
systemd:
enabled: false
unix: {}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ attestors:
enabled: false
systemd:
enabled: false
unix: {}
selector:
name: my-workload-identity
credential_ttl: 1m0s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ attestors:
enabled: false
systemd:
enabled: false
unix: {}
selector:
name: my-workload-identity
6 changes: 5 additions & 1 deletion lib/tbot/workloadidentity/workloadattest/attest.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Config struct {
Podman PodmanAttestorConfig `yaml:"podman"`
Docker DockerAttestorConfig `yaml:"docker"`
Systemd SystemdAttestorConfig `yaml:"systemd"`
Unix UnixAttestorConfig `yaml:"unix"`
}

func (c *Config) CheckAndSetDefaults() error {
Expand All @@ -60,14 +61,17 @@ func (c *Config) CheckAndSetDefaults() error {
if err := c.Docker.CheckAndSetDefaults(); err != nil {
return trace.Wrap(err, "validating docker")
}
if err := c.Unix.CheckAndSetDefaults(); err != nil {
return trace.Wrap(err, "validating unix")
}
return nil
}

// NewAttestor returns an Attestor from the given config.
func NewAttestor(log *slog.Logger, cfg Config) (*Attestor, error) {
att := &Attestor{
log: log,
unix: NewUnixAttestor(),
unix: NewUnixAttestor(cfg.Unix, log),
}
if cfg.Kubernetes.Enabled {
att.kubernetes = NewKubernetesAttestor(cfg.Kubernetes, log)
Expand Down
107 changes: 105 additions & 2 deletions lib/tbot/workloadidentity/workloadattest/unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,70 @@ package workloadattest

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log/slog"

"github.com/gravitational/trace"
"github.com/shirou/gopsutil/v4/process"

workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
)

// DefaultBinaryHashMaxBytes is default value for BinaryHashMaxSizeBytes.
const DefaultBinaryHashMaxBytes = 1 << 30 // 1GiB

// UnixAttestorConfig holds the configuration for the Unix workload attestor.
type UnixAttestorConfig struct {
// BinaryHashMaxSize is the maximum number of bytes that will be read from
// a process' binary to calculate its SHA256 checksum. If the binary is
// larger than this, the `binary_hash` attribute will be empty (to prevent
// DoS attacks).
//
// Defaults to 1GiB. Set it to -1 to make it unlimited.
BinaryHashMaxSizeBytes int64 `yaml:"binary_hash_max_size_bytes,omitempty"`
}

func (u *UnixAttestorConfig) CheckAndSetDefaults() error {
if u.BinaryHashMaxSizeBytes == 0 {
u.BinaryHashMaxSizeBytes = DefaultBinaryHashMaxBytes
}
if u.BinaryHashMaxSizeBytes < -1 {
return trace.BadParameter("binary_hash_max_size_bytes must be -1 (unlimited), 0 (default), or greater")
}
return nil
}

// UnixAttestor attests a process id to a Unix process.
type UnixAttestor struct {
cfg UnixAttestorConfig
log *slog.Logger
os UnixOS
}

// UnixOS is a handle on the operating system-specific features used by the Unix
// workload attestor.
type UnixOS interface {
// ExePath returns the filesystem path of the given process' executable.
ExePath(ctx context.Context, proc *process.Process) (string, error)

// OpenExe opens the given process' executable for reading.
//
// Use this rather than `os.Open(ExePath(proc))` because operating systems
// like Linux provide ways to read the original executable when the file on
// disk is replaced or modified.
OpenExe(ctx context.Context, proc *process.Process) (io.ReadCloser, error)
}

// NewUnixAttestor returns a new UnixAttestor.
func NewUnixAttestor() *UnixAttestor {
return &UnixAttestor{}
func NewUnixAttestor(cfg UnixAttestorConfig, log *slog.Logger) *UnixAttestor {
return &UnixAttestor{
cfg: cfg,
log: log,
os: unixOS,
}
}

// Attest attests a process id to a Unix process.
Expand Down Expand Up @@ -89,5 +139,58 @@ func (a *UnixAttestor) Attest(ctx context.Context, pid int) (*workloadidentityv1
att.Uid = uids[1]
}

path, err := a.os.ExePath(ctx, p)
switch {
case trace.IsNotFound(err):
// We could not find the executable because we're in a different mount namespace.
case err != nil:
a.log.ErrorContext(ctx, "Failed to find workload executable", "error", err)
default:
att.BinaryPath = &path
}

exe, err := a.os.OpenExe(ctx, p)
if err != nil {
a.log.ErrorContext(ctx, "Failed to open workload executable for hashing", "error", err)
return att, nil
}
defer func() { _ = exe.Close() }()

hash := sha256.New()
if _, err := copyAtMost(hash, exe, a.cfg.BinaryHashMaxSizeBytes); err != nil {
a.log.ErrorContext(ctx, "Failed to hash workload executable", "error", err)
return att, nil
}
sum := hex.EncodeToString(hash.Sum(nil))
att.BinaryHash = &sum

return att, nil
}

// copyAtMost copies at most n bytes from src to dst. If src contains more than
// n bytes, a LimitExceeded error will be returned.
func copyAtMost(dst io.Writer, src io.Reader, n int64) (int64, error) {
// -1 is unlimited.
if n == -1 {
return io.Copy(dst, src)
}

copied, err := io.CopyN(dst, src, n)
switch {
case errors.Is(err, io.EOF):
return copied, nil
case err != nil:
return 0, err
}

// Try to read one more byte to see if we reached the end of src.
_, err = src.Read([]byte{0})
switch {
case errors.Is(err, io.EOF):
return copied, nil
case err != nil:
return 0, err
default:
return 0, trace.LimitExceeded("input is larger than limit (%d)", n)
}
}
61 changes: 61 additions & 0 deletions lib/tbot/workloadidentity/workloadattest/unix_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//go:build linux

/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package workloadattest

import (
"context"
"io"
"os"
"path/filepath"
"strconv"

"github.com/shirou/gopsutil/v4/process"
)

var unixOS UnixOS = linux{}

type linux struct{}

func (l linux) ExePath(ctx context.Context, proc *process.Process) (string, error) {
return proc.ExeWithContext(ctx)
}

func (l linux) OpenExe(ctx context.Context, proc *process.Process) (io.ReadCloser, error) {
// On Linux, `/proc/<pid>/exe` is a symlink to the *inode* of the process'
// executable rather than a simple path, this means it won't change even if
// you replace the file on disk.
//
// In other words, during a rolling deployment the binary's hash won't change
// until the process has actually been restarted - which is desirable.
//
// With one important caveat: network filesystems typically do not guarantee
// inode stability, so if the process' binary is on a network mount, it's
// possible the hash won't match the binary the process is actually running.
return os.Open(l.procPath(strconv.Itoa(int(proc.Pid)), "exe"))
}

func (l linux) procPath(parts ...string) string {
base := os.Getenv("HOST_PROC")
if base == "" {
base = "/proc"
}
return filepath.Join(append([]string{base}, parts...)...)
}
46 changes: 46 additions & 0 deletions lib/tbot/workloadidentity/workloadattest/unix_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//go:build !linux

/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package workloadattest

import (
"context"
"io"
"os"

"github.com/shirou/gopsutil/v4/process"
)

var unixOS UnixOS = nonLinux{}

// nonLinux implements the UnixOS interface for non-Linux systems.
type nonLinux struct{}

func (nonLinux) ExePath(ctx context.Context, proc *process.Process) (string, error) {
return proc.ExeWithContext(ctx)
}

func (n nonLinux) OpenExe(ctx context.Context, proc *process.Process) (io.ReadCloser, error) {
path, err := n.ExePath(ctx, proc)
if err != nil {
return nil, err
}
return os.Open(path)
}
Loading
Loading