From 6922aa0b500bca8c3e3c4636db054993eaa9be83 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 31 Jul 2024 15:34:42 +0100 Subject: [PATCH] Workload Identity: Kubernetes Workload Attestation (#44209) * Start hacking on resolving pod/container id from pid * Add godoc comments * Tidy attestation into well defined types * Use gopsutil to determine gid/uid on unix systems * Start threading through config * Update tests * Start working TLS support into kubelet api client * Thread through configuration to yaml * Support loading the CA * Start testing with real cluster/bug fixes * Simplify by removing container lookup * Add new attestation rules/tests for new attestation rules * Add test that leverages example mountfiles * Start handling kubelet client auth more elegantly * Add handling of custom CA values * Tie together configuration validation * Update YAML tests * Go mod/sum * Ensure we use the Effective UID/GID rather than "Real" UID/GID in Unix attestation * Add testdata from GCP * Add test of Kubernetes attestation with mock kubelet API * Add test for UnixAttestor * Update YAML goldenfile * Appease liinter * Remove change to session.go * Add timeout to Kubelet client * Import `time` * Go mod tidy * Go mod tidy * Remove TODO about renaming * Rename attestor -> attestors * Add stubs on windows * Add missing license header --- go.mod | 10 +- go.sum | 21 ++ integrations/terraform/go.mod | 8 + integrations/terraform/go.sum | 20 ++ .../config/service_spiffe_workload_api.go | 34 +- .../service_spiffe_workload_api_test.go | 19 ++ .../TestBotConfig_YAML/standard_config.golden | 5 + .../full.golden | 14 + .../minimal.golden | 3 + lib/tbot/service_spiffe_workload_api.go | 165 ++++++--- lib/tbot/service_spiffe_workload_api_test.go | 228 +++++++++++-- lib/tbot/spiffe/workloadattest/attest.go | 107 ++++++ lib/tbot/spiffe/workloadattest/kubernetes.go | 135 ++++++++ .../spiffe/workloadattest/kubernetes_unix.go | 316 ++++++++++++++++++ .../workloadattest/kubernetes_unix_test.go | 178 ++++++++++ .../workloadattest/kubernetes_windows.go | 41 +++ .../mountfile/k8s-real-docker-desktop | 22 ++ .../k8s-real-gcp-v1.29.5-gke.1091002 | 24 ++ .../k8s-real-k3s-ubuntu-v1.28.6+k3s2 | 27 ++ .../testdata/mountfile/k8s-real-orbstack | 24 ++ lib/tbot/spiffe/workloadattest/unix.go | 122 +++++++ lib/tbot/spiffe/workloadattest/unix_test.go | 46 +++ 22 files changed, 1505 insertions(+), 64 deletions(-) create mode 100644 lib/tbot/spiffe/workloadattest/attest.go create mode 100644 lib/tbot/spiffe/workloadattest/kubernetes.go create mode 100644 lib/tbot/spiffe/workloadattest/kubernetes_unix.go create mode 100644 lib/tbot/spiffe/workloadattest/kubernetes_unix_test.go create mode 100644 lib/tbot/spiffe/workloadattest/kubernetes_windows.go create mode 100644 lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-docker-desktop create mode 100644 lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-gcp-v1.29.5-gke.1091002 create mode 100644 lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-k3s-ubuntu-v1.28.6+k3s2 create mode 100644 lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-orbstack create mode 100644 lib/tbot/spiffe/workloadattest/unix.go create mode 100644 lib/tbot/spiffe/workloadattest/unix_test.go diff --git a/go.mod b/go.mod index a38a0ccd22a57..9a92877243381 100644 --- a/go.mod +++ b/go.mod @@ -167,6 +167,7 @@ require ( github.com/schollz/progressbar/v3 v3.14.4 github.com/scim2/filter-parser/v2 v2.2.0 github.com/segmentio/parquet-go v0.0.0-20230712180008-5d42db8f0d47 + github.com/shirou/gopsutil/v4 v4.24.6 github.com/sigstore/cosign/v2 v2.2.4 github.com/sigstore/sigstore v1.8.6 github.com/sijms/go-ora/v2 v2.8.10 @@ -336,6 +337,7 @@ require ( github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -414,6 +416,7 @@ require ( github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lithammer/dedent v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect @@ -457,6 +460,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pkg/xattr v0.4.9 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/pquerna/cachecontrol v0.1.0 // indirect github.com/prometheus/procfs v0.13.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -474,6 +478,7 @@ require ( github.com/segmentio/encoding v0.4.0 // indirect github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 // indirect github.com/siddontang/go-log v0.0.0-20180807004314-8d05993dda07 // indirect @@ -490,6 +495,8 @@ require ( github.com/thales-e-security/pool v0.0.2 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/vbatts/tar-split v0.11.5 // indirect github.com/weppos/publicsuffix-go v0.30.3-0.20240510084413-5f1d03393b3d // indirect @@ -505,6 +512,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/errs v1.3.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c // indirect @@ -528,7 +536,7 @@ require ( k8s.io/component-helpers v0.30.0 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect k8s.io/metrics v0.30.0 // indirect - k8s.io/utils v0.0.0-20240102154912-e7106e64919e // indirect + k8s.io/utils v0.0.0-20240102154912-e7106e64919e mvdan.cc/sh/v3 v3.7.0 // indirect oras.land/oras-go v1.2.5 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect diff --git a/go.sum b/go.sum index c0ae6fa8ba340..7f041d406c376 100644 --- a/go.sum +++ b/go.sum @@ -1256,6 +1256,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= @@ -1807,6 +1809,8 @@ github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffkt github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= @@ -2037,6 +2041,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= @@ -2138,6 +2144,12 @@ github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df h1:S77Pf5fIG github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df/go.mod h1:dcuzJZ83w/SqN9k4eQqwKYMgmKWzg/KzJAURBhRL1tc= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/shirou/gopsutil/v4 v4.24.6 h1:9qqCSYF2pgOU+t+NgJtp7Co5+5mHF/HyKBUckySQL64= +github.com/shirou/gopsutil/v4 v4.24.6/go.mod h1:aoebb2vxetJ/yIDZISmduFvVNPHqXQ9SEJwRXxkf0RA= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= @@ -2234,6 +2246,10 @@ github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= @@ -2284,6 +2300,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE= @@ -2627,6 +2645,7 @@ golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2656,6 +2675,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2713,6 +2733,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod index 4512760306376..cbdc796c5b3dc 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -152,6 +152,7 @@ require ( github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -235,6 +236,7 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailgun/holster/v3 v3.16.2 // indirect github.com/mailgun/minheap v0.0.0-20170619185613-3dbe6c6bf55f // indirect github.com/mailgun/timetools v0.0.0-20170619190023-f3a7b8ffff47 // indirect @@ -273,6 +275,7 @@ require ( github.com/pkg/sftp v1.13.6 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/posener/complete v1.2.3 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/pquerna/cachecontrol v0.1.0 // indirect github.com/pquerna/otp v1.4.0 // indirect github.com/prometheus/client_golang v1.19.1 // indirect @@ -287,6 +290,8 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/schollz/progressbar/v3 v3.14.4 // indirect github.com/scim2/filter-parser/v2 v2.2.0 // indirect + github.com/shirou/gopsutil/v4 v4.24.6 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sijms/go-ora/v2 v2.8.10 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -295,6 +300,8 @@ require ( github.com/spiffe/go-spiffe/v2 v2.3.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/thales-e-security/pool v0.0.2 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect @@ -309,6 +316,7 @@ require ( github.com/xlab/treeprint v1.2.0 // indirect github.com/yuin/goldmark v1.7.4 // indirect github.com/yuin/goldmark-meta v1.1.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c // indirect github.com/zmap/zlint/v3 v3.6.0 // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index 17b19d460949c..fdafa7efc9e7b 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -1091,6 +1091,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -1571,6 +1573,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= @@ -1743,6 +1747,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= @@ -1815,6 +1821,12 @@ github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNX github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shirou/gopsutil/v4 v4.24.6 h1:9qqCSYF2pgOU+t+NgJtp7Co5+5mHF/HyKBUckySQL64= +github.com/shirou/gopsutil/v4 v4.24.6/go.mod h1:aoebb2vxetJ/yIDZISmduFvVNPHqXQ9SEJwRXxkf0RA= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= @@ -1868,6 +1880,10 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg= github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -1923,6 +1939,8 @@ github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUei github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE= @@ -2267,6 +2285,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2321,6 +2340,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/lib/tbot/config/service_spiffe_workload_api.go b/lib/tbot/config/service_spiffe_workload_api.go index 59c2db2cbb315..5c034653b740b 100644 --- a/lib/tbot/config/service_spiffe_workload_api.go +++ b/lib/tbot/config/service_spiffe_workload_api.go @@ -23,6 +23,8 @@ import ( "github.com/gravitational/trace" "gopkg.in/yaml.v3" + + "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" ) const SPIFFEWorkloadAPIServiceType = "spiffe-workload-api" @@ -58,6 +60,27 @@ type SVIDRequestRuleUnix struct { GID *int `yaml:"gid,omitempty"` } +// SVIDRequestRuleKubernetes is a workload attestation ruleset for workloads +// that connect via Unix domain sockets and are running in a Kubernetes pod. +// +// Requires the "kubernetes" attestor to be enabled. +// +// Fields should be a subset of workloadattest.KubernetesAttestation. +type SVIDRequestRuleKubernetes struct { + // Namespace is the Kubernetes namespace that a workload must be running in + // to be issued this SVID. + // If unspecified, the namespace is not checked. + Namespace string `yaml:"namespace,omitempty"` + // ServiceAccount is the Kubernetes service account that a workload must be + // running as to be issued this SVID. + // If unspecified, the service account is not checked. + ServiceAccount string `yaml:"service_account,omitempty"` + // PodName is the Kubernetes pod name that a workload must be running in to + // be issued this SVID. + // If unspecified, the pod name is not checked. + PodName string `yaml:"pod_name,omitempty"` +} + // SVIDRequestRule is an individual workload attestation rule. All values // specified within the rule must be satisfied for the rule itself to pass. type SVIDRequestRule struct { @@ -65,6 +88,10 @@ type SVIDRequestRule struct { // Unix domain sockets. If any value here is set, the rule will not pass // unless the workload is connecting via a Unix domain socket. Unix SVIDRequestRuleUnix `yaml:"unix"` + // Kubernetes is the workload attestation ruleset for workloads that connect + // via the Unix domain socket and are running in a Kubernetes pod. + // The "kubernetes" attestor must be enabled or these rules will fail. + Kubernetes SVIDRequestRuleKubernetes `yaml:"kubernetes"` } func (o SVIDRequestRule) LogValue() slog.Value { @@ -92,6 +119,8 @@ type SPIFFEWorkloadAPIService struct { // SVIDs is the list of SVIDs that the SPIFFE Workload API server should // provide. SVIDs []SVIDRequestWithRules `yaml:"svids"` + // Attestors is the configuration for the workload attestation process. + Attestors workloadattest.Config `yaml:"attestors"` } func (s *SPIFFEWorkloadAPIService) Type() string { @@ -121,8 +150,11 @@ func (s *SPIFFEWorkloadAPIService) CheckAndSetDefaults() error { } for i, svid := range s.SVIDs { if err := svid.CheckAndSetDefaults(); err != nil { - return trace.Wrap(err, "validiting svid[%d]", i) + return trace.Wrap(err, "validating svid[%d]", i) } } + if err := s.Attestors.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err, "validating attestor") + } return nil } diff --git a/lib/tbot/config/service_spiffe_workload_api_test.go b/lib/tbot/config/service_spiffe_workload_api_test.go index 1e4334730490c..795cf38b00896 100644 --- a/lib/tbot/config/service_spiffe_workload_api_test.go +++ b/lib/tbot/config/service_spiffe_workload_api_test.go @@ -20,6 +20,8 @@ package config import ( "testing" + + "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" ) func ptr[T any](v T) *T { @@ -34,6 +36,18 @@ func TestSPIFFEWorkloadAPIService_YAML(t *testing.T) { name: "full", in: SPIFFEWorkloadAPIService{ Listen: "unix:///var/run/spiffe.sock", + Attestors: workloadattest.Config{ + Kubernetes: workloadattest.KubernetesAttestorConfig{ + Enabled: true, + Kubelet: workloadattest.KubeletClientConfig{ + SecurePort: 12345, + TokenPath: "/path/to/token", + CAPath: "/path/to/ca.pem", + SkipVerify: true, + Anonymous: true, + }, + }, + }, SVIDs: []SVIDRequestWithRules{ { SVIDRequest: SVIDRequest{ @@ -56,6 +70,11 @@ func TestSPIFFEWorkloadAPIService_YAML(t *testing.T) { Unix: SVIDRequestRuleUnix{ PID: ptr(100), }, + Kubernetes: SVIDRequestRuleKubernetes{ + Namespace: "my-namespace", + PodName: "my-pod", + ServiceAccount: "service-account", + }, }, }, }, diff --git a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden index 31eacda54d8c0..53a59aceacc8e 100644 --- a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden +++ b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden @@ -35,8 +35,13 @@ services: pid: 100 uid: 1000 gid: 1234 + kubernetes: {} - unix: pid: 100 + kubernetes: {} + attestors: + kubernetes: + enabled: false - type: example message: llama - type: ssh-multiplexer diff --git a/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden b/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden index 53fc172b4dc4b..4a7c696e887bf 100644 --- a/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden +++ b/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden @@ -14,5 +14,19 @@ svids: pid: 100 uid: 1000 gid: 1234 + kubernetes: {} - unix: pid: 100 + kubernetes: + namespace: my-namespace + service_account: service-account + pod_name: my-pod +attestors: + kubernetes: + enabled: true + kubelet: + secure_port: 12345 + token_path: /path/to/token + ca_path: /path/to/ca.pem + skip_verify: true + anonymous: true diff --git a/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden b/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden index 1cef5bf13e1e0..326b4dfab8a67 100644 --- a/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden +++ b/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden @@ -2,3 +2,6 @@ type: spiffe-workload-api listen: unix:///var/run/spiffe.sock svids: - path: /foo +attestors: + kubernetes: + enabled: false diff --git a/lib/tbot/service_spiffe_workload_api.go b/lib/tbot/service_spiffe_workload_api.go index 680c9fa56f5ec..b04fac4d3f9e5 100644 --- a/lib/tbot/service_spiffe_workload_api.go +++ b/lib/tbot/service_spiffe_workload_api.go @@ -54,6 +54,7 @@ import ( "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" "github.com/gravitational/teleport/lib/uds" ) @@ -87,6 +88,8 @@ type SPIFFEWorkloadAPIService struct { trustDomain string + attestor *workloadattest.Attestor + // trustBundle is protected by trustBundleMu. Use setTrustBundle and // getTrustBundle to access it. trustBundle []byte @@ -170,6 +173,11 @@ func (s *SPIFFEWorkloadAPIService) setup(ctx context.Context) (err error) { } s.trustDomain = authPing.ClusterName + s.attestor, err = workloadattest.NewAttestor(s.log, s.cfg.Attestors) + if err != nil { + return trace.Wrap(err, "setting up workload attestation") + } + return nil } @@ -419,11 +427,16 @@ func (s *SPIFFEWorkloadAPIService) fetchX509SVIDs( // filterSVIDRequests filters the SVID requests based on the workload // attestation. +// +// TODO(noah): In a future PR, we need to totally refactor this to a more +// flexible rules engine otherwise this is going to get absurdly large as +// we add more types. Ideally, something that would be compatible with a +// predicate language would be great. func filterSVIDRequests( ctx context.Context, log *slog.Logger, svidRequests []config.SVIDRequestWithRules, - udsCreds *uds.Creds, + att workloadattest.Attestation, ) []config.SVIDRequest { var filtered []config.SVIDRequest for _, req := range svidRequests { @@ -442,33 +455,91 @@ func filterSVIDRequests( match := false for _, rule := range req.Rules { log := log.With("rule", rule) - log.DebugContext( - ctx, - "Evaluating rule against workload attestation", - ) - if rule.Unix.UID != nil && (udsCreds == nil || *rule.Unix.UID != udsCreds.UID) { + logMismatch := func(field string, want any, got any) { log.DebugContext( ctx, "Rule did not match workload attestation", - "field", "unix.uid", + "field", field, + "want", want, + "got", got, ) - continue } - if rule.Unix.PID != nil && (udsCreds == nil || *rule.Unix.PID != udsCreds.PID) { + logNotAttested := func(requiredAttestor string) { log.DebugContext( ctx, - "Rule did not match workload attestation", - "field", "unix.pid", + "Workload did not complete attestation required for this rule", + "required_attestor", requiredAttestor, ) - continue } - if rule.Unix.GID != nil && (udsCreds == nil || *rule.Unix.GID != udsCreds.GID) { - log.DebugContext( - ctx, - "Rule did not match workload attestation", - "field", "unix.gid", - ) - continue + log.DebugContext( + ctx, + "Evaluating rule against workload attestation", + ) + if rule.Unix.UID != nil { + if !att.Unix.Attested { + logNotAttested("unix") + continue + } + if *rule.Unix.UID != att.Unix.UID { + logMismatch("unix.uid", *rule.Unix.UID, att.Unix.UID) + continue + } + // Rule field matched! + } + if rule.Unix.PID != nil { + if !att.Unix.Attested { + logNotAttested("unix") + continue + } + if *rule.Unix.PID != att.Unix.PID { + logMismatch("unix.pid", *rule.Unix.PID, att.Unix.PID) + continue + } + // Rule field matched! + } + if rule.Unix.GID != nil { + if !att.Unix.Attested { + logNotAttested("unix") + continue + } + if *rule.Unix.GID != att.Unix.GID { + logMismatch("unix.gid", *rule.Unix.GID, att.Unix.GID) + continue + } + // Rule field matched! + } + if rule.Kubernetes.Namespace != "" { + if !att.Kubernetes.Attested { + logNotAttested("kubernetes") + continue + } + if rule.Kubernetes.Namespace != att.Kubernetes.Namespace { + logMismatch("kubernetes.namespace", rule.Kubernetes.Namespace, att.Kubernetes.Namespace) + continue + } + // Rule field matched! + } + if rule.Kubernetes.PodName != "" { + if !att.Kubernetes.Attested { + logNotAttested("kubernetes") + continue + } + if rule.Kubernetes.PodName != att.Kubernetes.PodName { + logMismatch("kubernetes.pod_name", rule.Kubernetes.PodName, att.Kubernetes.PodName) + continue + } + // Rule field matched! + } + if rule.Kubernetes.ServiceAccount != "" { + if !att.Kubernetes.Attested { + logNotAttested("kubernetes") + continue + } + if rule.Kubernetes.ServiceAccount != att.Kubernetes.ServiceAccount { + logMismatch("kubernetes.service_account", rule.Kubernetes.ServiceAccount, att.Kubernetes.ServiceAccount) + continue + } + // Rule field matched! } log.DebugContext( @@ -489,49 +560,59 @@ func filterSVIDRequests( return filtered } -// FetchX509SVID generates and returns the X.509 SVIDs available to a workload. -// It is a streaming RPC, and sends renewed SVIDs to the client before they -// expire. -// Implements the SPIFFE Workload API FetchX509SVID method. -func (s *SPIFFEWorkloadAPIService) FetchX509SVID( - _ *workloadpb.X509SVIDRequest, - srv workloadpb.SpiffeWorkloadAPI_FetchX509SVIDServer, -) error { - renewCh, unsubscribe := s.trustBundleBroadcast.subscribe() - defer unsubscribe() - ctx := srv.Context() +func (s *SPIFFEWorkloadAPIService) authenticateClient(ctx context.Context) (*slog.Logger, workloadattest.Attestation, error) { + // The zero value of the attestation is equivalent to no attestation. + var att workloadattest.Attestation p, ok := peer.FromContext(ctx) if !ok { - return trace.BadParameter("peer not found in context") + return nil, att, trace.BadParameter("peer not found in context") } log := s.log authInfo, ok := p.AuthInfo.(uds.AuthInfo) + if ok && authInfo.Creds != nil { + var err error + att, err = s.attestor.Attest(ctx, authInfo.Creds.PID) + if err != nil { + return nil, att, trace.Wrap(err, "performing workload attestation") + } log = log.With( - slog.Group("workload", - slog.Group("unix", - "pid", authInfo.Creds.PID, - "uid", authInfo.Creds.UID, - "gid", authInfo.Creds.GID, - ), - ), + "workload", slog.LogValuer(att), ) } if p.Addr.String() != "" { log = log.With( - slog.Group("workload", - slog.String("addr", p.Addr.String()), - ), + slog.String("remote_addr", p.Addr.String()), ) } + return log, att, nil +} + +// FetchX509SVID generates and returns the X.509 SVIDs available to a workload. +// It is a streaming RPC, and sends renewed SVIDs to the client before they +// expire. +// Implements the SPIFFE Workload API FetchX509SVID method. +func (s *SPIFFEWorkloadAPIService) FetchX509SVID( + _ *workloadpb.X509SVIDRequest, + srv workloadpb.SpiffeWorkloadAPI_FetchX509SVIDServer, +) error { + renewCh, unsubscribe := s.trustBundleBroadcast.subscribe() + defer unsubscribe() + ctx := srv.Context() + + log, creds, err := s.authenticateClient(ctx) + if err != nil { + return trace.Wrap(err) + } + log.InfoContext(ctx, "FetchX509SVID stream opened by workload") defer log.InfoContext(ctx, "FetchX509SVID stream has closed") // Before we issue the SVIDs to the workload, we need to complete workload // attestation and determine which SVIDs to issue. - svidReqs := filterSVIDRequests(ctx, log, s.cfg.SVIDs, authInfo.Creds) + svidReqs := filterSVIDRequests(ctx, log, s.cfg.SVIDs, creds) // The SPIFFE Workload API (5.2.1): // diff --git a/lib/tbot/service_spiffe_workload_api_test.go b/lib/tbot/service_spiffe_workload_api_test.go index 1638a836f6e41..3a331a1a1f06d 100644 --- a/lib/tbot/service_spiffe_workload_api_test.go +++ b/lib/tbot/service_spiffe_workload_api_test.go @@ -26,7 +26,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/gravitational/teleport/lib/tbot/config" - "github.com/gravitational/teleport/lib/uds" + "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" "github.com/gravitational/teleport/lib/utils" ) @@ -35,17 +35,18 @@ func ptr[T any](v T) *T { } func TestSPIFFEWorkloadAPIService_filterSVIDRequests(t *testing.T) { + // This test is more for overall behavior. Use the _field test for + // each individual field. ctx := context.Background() log := utils.NewSlogLoggerForTests() tests := []struct { name string - uds *uds.Creds + att workloadattest.Attestation in []config.SVIDRequestWithRules want []config.SVIDRequest }{ { name: "no rules", - uds: nil, in: []config.SVIDRequestWithRules{ { SVIDRequest: config.SVIDRequest{ @@ -68,11 +69,14 @@ func TestSPIFFEWorkloadAPIService_filterSVIDRequests(t *testing.T) { }, }, { - name: "no rules with uds", - uds: &uds.Creds{ - UID: 1000, - GID: 1001, - PID: 1002, + name: "no rules with attestation", + att: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + UID: 1000, + GID: 1001, + PID: 1002, + }, }, in: []config.SVIDRequestWithRules{ { @@ -96,11 +100,43 @@ func TestSPIFFEWorkloadAPIService_filterSVIDRequests(t *testing.T) { }, }, { - name: "no matching rules with uds", - uds: &uds.Creds{ - UID: 1000, - GID: 1001, - PID: 1002, + name: "no rules with attestation", + att: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + // We don't expect that workloadattest will ever return + // Attested: false and include UID/PID/GID but we want to + // ensure we handle this by failing regardless. + Attested: false, + UID: 1000, + GID: 1001, + PID: 1002, + }, + }, + in: []config.SVIDRequestWithRules{ + { + SVIDRequest: config.SVIDRequest{ + Path: "/foo", + }, + Rules: []config.SVIDRequestRule{ + { + Unix: config.SVIDRequestRuleUnix{ + UID: ptr(1000), + }, + }, + }, + }, + }, + want: nil, + }, + { + name: "no matching rules with attestation", + att: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + UID: 1000, + GID: 1001, + PID: 1002, + }, }, in: []config.SVIDRequestWithRules{ { @@ -137,8 +173,7 @@ func TestSPIFFEWorkloadAPIService_filterSVIDRequests(t *testing.T) { want: nil, }, { - name: "no matching rules without uds", - uds: nil, + name: "no matching rules without attestation", in: []config.SVIDRequestWithRules{ { SVIDRequest: config.SVIDRequest{ @@ -174,10 +209,13 @@ func TestSPIFFEWorkloadAPIService_filterSVIDRequests(t *testing.T) { }, { name: "some matching rules with uds", - uds: &uds.Creds{ - UID: 1000, - GID: 1001, - PID: 1002, + att: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + UID: 1000, + GID: 1001, + PID: 1002, + }, }, in: []config.SVIDRequestWithRules{ { @@ -230,8 +268,158 @@ func TestSPIFFEWorkloadAPIService_filterSVIDRequests(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := filterSVIDRequests(ctx, log, tt.in, tt.uds) + got := filterSVIDRequests(ctx, log, tt.in, tt.att) assert.Empty(t, gocmp.Diff(tt.want, got)) }) } } + +func TestSPIFFEWorkloadAPIService_filterSVIDRequests_field(t *testing.T) { + ctx := context.Background() + log := utils.NewSlogLoggerForTests() + tests := []struct { + field string + matching workloadattest.Attestation + nonMatching workloadattest.Attestation + rule config.SVIDRequestRule + }{ + { + field: "unix.pid", + rule: config.SVIDRequestRule{ + Unix: config.SVIDRequestRuleUnix{ + PID: ptr(1000), + }, + }, + matching: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + PID: 1000, + }, + }, + nonMatching: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + PID: 200, + }, + }, + }, + { + field: "unix.uid", + rule: config.SVIDRequestRule{ + Unix: config.SVIDRequestRuleUnix{ + UID: ptr(1000), + }, + }, + matching: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + UID: 1000, + }, + }, + nonMatching: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + UID: 200, + }, + }, + }, + { + field: "unix.gid", + rule: config.SVIDRequestRule{ + Unix: config.SVIDRequestRuleUnix{ + GID: ptr(1000), + }, + }, + matching: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + GID: 1000, + }, + }, + nonMatching: workloadattest.Attestation{ + Unix: workloadattest.UnixAttestation{ + Attested: true, + GID: 200, + }, + }, + }, + { + field: "unix.namespace", + rule: config.SVIDRequestRule{ + Kubernetes: config.SVIDRequestRuleKubernetes{ + Namespace: "foo", + }, + }, + matching: workloadattest.Attestation{ + Kubernetes: workloadattest.KubernetesAttestation{ + Attested: true, + Namespace: "foo", + }, + }, + nonMatching: workloadattest.Attestation{ + Kubernetes: workloadattest.KubernetesAttestation{ + Attested: true, + Namespace: "bar", + }, + }, + }, + { + field: "kubernetes.service_account", + rule: config.SVIDRequestRule{ + Kubernetes: config.SVIDRequestRuleKubernetes{ + ServiceAccount: "foo", + }, + }, + matching: workloadattest.Attestation{ + Kubernetes: workloadattest.KubernetesAttestation{ + Attested: true, + ServiceAccount: "foo", + }, + }, + nonMatching: workloadattest.Attestation{ + Kubernetes: workloadattest.KubernetesAttestation{ + Attested: true, + ServiceAccount: "bar", + }, + }, + }, + { + field: "kubernetes.pod_name", + rule: config.SVIDRequestRule{ + Kubernetes: config.SVIDRequestRuleKubernetes{ + PodName: "foo", + }, + }, + matching: workloadattest.Attestation{ + Kubernetes: workloadattest.KubernetesAttestation{ + Attested: true, + PodName: "foo", + }, + }, + nonMatching: workloadattest.Attestation{ + Kubernetes: workloadattest.KubernetesAttestation{ + Attested: true, + PodName: "bar", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + rules := []config.SVIDRequestWithRules{ + { + SVIDRequest: config.SVIDRequest{ + Path: "/foo", + }, + Rules: []config.SVIDRequestRule{tt.rule}, + }, + } + t.Run("matching", func(t *testing.T) { + assert.Len(t, filterSVIDRequests(ctx, log, rules, tt.matching), 1) + }) + t.Run("non-matching", func(t *testing.T) { + assert.Empty(t, filterSVIDRequests(ctx, log, rules, tt.nonMatching)) + }) + }) + } +} diff --git a/lib/tbot/spiffe/workloadattest/attest.go b/lib/tbot/spiffe/workloadattest/attest.go new file mode 100644 index 0000000000000..cd3d87cf65dc1 --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/attest.go @@ -0,0 +1,107 @@ +/* + * Teleport + * Copyright (C) 2024 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 . + */ + +package workloadattest + +import ( + "context" + "log/slog" + + "github.com/gravitational/trace" +) + +// Attestation holds the results of the attestation process carried out on a +// PID by the attestor. +type Attestation struct { + Unix UnixAttestation + Kubernetes KubernetesAttestation +} + +// LogValue implements slog.LogValue to provide a nicely formatted set of +// log keys for a given attestation. +func (a Attestation) LogValue() slog.Value { + return slog.GroupValue( + slog.Attr{ + Key: "unix", + Value: a.Unix.LogValue(), + }, + slog.Attr{ + Key: "kubernetes", + Value: a.Kubernetes.LogValue(), + }, + ) +} + +type attestor[T any] interface { + Attest(ctx context.Context, pid int) (T, error) +} + +// Attestor runs the workload attestation process on a given PID to determine +// key information about the process. +type Attestor struct { + log *slog.Logger + kubernetes attestor[KubernetesAttestation] + unix attestor[UnixAttestation] +} + +// Config is the configuration for Attestor +type Config struct { + Kubernetes KubernetesAttestorConfig `yaml:"kubernetes"` +} + +func (c *Config) CheckAndSetDefaults() error { + return trace.Wrap(c.Kubernetes.CheckAndSetDefaults(), "validating kubernetes") +} + +// NewAttestor returns an Attestor from the given config. +func NewAttestor(log *slog.Logger, cfg Config) (*Attestor, error) { + att := &Attestor{ + log: log, + unix: NewUnixAttestor(), + } + if cfg.Kubernetes.Enabled { + att.kubernetes = NewKubernetesAttestor(cfg.Kubernetes, log) + } + return att, nil +} + +func (a *Attestor) Attest(ctx context.Context, pid int) (Attestation, error) { + a.log.DebugContext(ctx, "Starting workload attestation", "pid", pid) + defer a.log.DebugContext(ctx, "Finished workload attestation complete", "pid", pid) + + att := Attestation{} + var err error + + // We always perform the unix attestation first + att.Unix, err = a.unix.Attest(ctx, pid) + if err != nil { + return att, err + } + + // Then we can perform the optionally configured attestations + // For these, failure is soft. If it fails, we log, but still return the + // successfully attested data. + if a.kubernetes != nil { + att.Kubernetes, err = a.kubernetes.Attest(ctx, pid) + if err != nil { + a.log.WarnContext(ctx, "Failed to perform Kubernetes workload attestation", "error", err) + } + } + + return att, nil +} diff --git a/lib/tbot/spiffe/workloadattest/kubernetes.go b/lib/tbot/spiffe/workloadattest/kubernetes.go new file mode 100644 index 0000000000000..afadbab5c45e4 --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/kubernetes.go @@ -0,0 +1,135 @@ +/* + * Teleport + * Copyright (C) 2024 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 . + */ + +package workloadattest + +import ( + "log/slog" + + "github.com/gravitational/trace" +) + +// KubernetesAttestation holds the Kubernetes pod information retrieved from +// the workload attestation process. +type KubernetesAttestation struct { + // Attested is true if the PID was successfully attested to a Kubernetes + // pod. This indicates the validity of the rest of the fields. + Attested bool + // Namespace is the namespace of the pod. + Namespace string + // ServiceAccount is the service account of the pod. + ServiceAccount string + // PodName is the name of the pod. + PodName string + // PodUID is the UID of the pod. + PodUID string + // Labels is a map of labels on the pod. + Labels map[string]string +} + +// LogValue implements slog.LogValue to provide a nicely formatted set of +// log keys for a given attestation. +func (a KubernetesAttestation) LogValue() slog.Value { + values := []slog.Attr{ + slog.Bool("attested", a.Attested), + } + if a.Attested { + labels := []slog.Attr{} + for k, v := range a.Labels { + labels = append(labels, slog.String(k, v)) + } + values = append(values, + slog.String("namespace", a.Namespace), + slog.String("service_account", a.ServiceAccount), + slog.String("pod_name", a.PodName), + slog.String("pod_uid", a.PodUID), + slog.Attr{ + Key: "labels", + Value: slog.GroupValue(labels...), + }, + ) + } + return slog.GroupValue(values...) +} + +// KubernetesAttestorConfig holds the configuration for the KubernetesAttestor. +type KubernetesAttestorConfig struct { + // Enabled is true if the KubernetesAttestor is enabled. If false, + // Kubernetes attestation will not be attempted. + Enabled bool `yaml:"enabled"` + Kubelet KubeletClientConfig `yaml:"kubelet,omitempty"` +} + +func (c *KubernetesAttestorConfig) CheckAndSetDefaults() error { + if !c.Enabled { + return nil + } + return trace.Wrap(c.Kubelet.CheckAndSetDefaults(), "validating kubelet") +} + +const ( + // nodeNameEnv is used to inject the current nodes name via the downward API. + // This provides a hostname for the kubelet client to use. + nodeNameEnv = "TELEPORT_NODE_NAME" + defaultServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + defaultSecurePort = 10250 +) + +// KubeletClientConfig holds the configuration for the Kubelet client +// used to query the Kubelet API for workload attestation. +type KubeletClientConfig struct { + // ReadOnlyPort is the port on which the Kubelet API is exposed for + // read-only operations. This is mutually exclusive with SecurePort. + // This is primarily left for legacy support - since Kubernetes 1.16, the + // read-only port is disabled by default. + ReadOnlyPort int `yaml:"read_only_port,omitempty"` + // SecurePort specifies the secure port on which the Kubelet API is exposed. + // If unspecified, this defaults to `10250`. This is mutually exclusive + // with ReadOnlyPort. + SecurePort int `yaml:"secure_port,omitempty"` + + // TokenPath is the path to the token file used to authenticate with the + // Kubelet API when using the secure port. + // Defaults to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + TokenPath string `yaml:"token_path,omitempty"` + // CAPath is the path to the CA file used to verify the certificate + // presented by Kubelet when using the secure port. + // Defaults to `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`. + CAPath string `yaml:"ca_path,omitempty"` + // SkipVerify is used to skip verification of the Kubelet's certificate when + // using the secure port. If set, CAPath will be ignored. + // + // This is useful in scenarios where Kubelet has not been configured with a + // valid certificate signed by the cluster CA. This is more common than + // you'd think. + SkipVerify bool `yaml:"skip_verify,omitempty"` + // Anonymous is used to indicate that no authentication should be used + // when connecting to the secure Kubelet API. If set, TokenPath will be + // ignored. + Anonymous bool `yaml:"anonymous,omitempty"` +} + +// CheckAndSetDefaults checks the KubeletClientConfig for any invalid values +// and sets defaults where necessary. +func (c KubeletClientConfig) CheckAndSetDefaults() error { + if c.ReadOnlyPort != 0 && c.SecurePort != 0 { + return trace.BadParameter("readOnlyPort and securePort are mutually exclusive") + } + return nil +} diff --git a/lib/tbot/spiffe/workloadattest/kubernetes_unix.go b/lib/tbot/spiffe/workloadattest/kubernetes_unix.go new file mode 100644 index 0000000000000..567b33d337d00 --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/kubernetes_unix.go @@ -0,0 +1,316 @@ +//go:build unix + +/* + * Teleport + * Copyright (C) 2024 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 . + */ + +package workloadattest + +import ( + "cmp" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gravitational/trace" + v1 "k8s.io/api/core/v1" + "k8s.io/utils/mount" +) + +// KubernetesAttestor attests a workload to a Kubernetes pod. +// +// It requires: +// +// - `hostPID: true` so we can view the /proc of other pods. +// - `TELEPORT_MY_NODE_NAME` to be set to the node name of the current node. +// - A service account that allows it to query the Kubelet API. +// +// It roughly takes the following steps: +// 1. From the PID, determine the container ID and pod ID from the +// /proc//mountinfo file. +// 2. Makes a request to the Kubelet API to list all pods on the node. +// 3. Find the pod and container with the matching ID. +// 4. Convert the pod information to a KubernetesAttestation. +type KubernetesAttestor struct { + kubeletClient *kubeletClient + log *slog.Logger + // rootPath specifies the location of `/`. This allows overriding for tests. + rootPath string +} + +// NewKubernetesAttestor creates a new KubernetesAttestor. +func NewKubernetesAttestor(cfg KubernetesAttestorConfig, log *slog.Logger) *KubernetesAttestor { + kubeletClient := newKubeletClient(cfg.Kubelet) + return &KubernetesAttestor{ + kubeletClient: kubeletClient, + log: log, + } +} + +// Attest resolves the Kubernetes pod information from the +// PID of the workload. +func (a *KubernetesAttestor) Attest(ctx context.Context, pid int) (KubernetesAttestation, error) { + a.log.DebugContext(ctx, "Starting Kubernetes workload attestation", "pid", pid) + + podID, containerID, err := a.getContainerAndPodID(pid) + if err != nil { + return KubernetesAttestation{}, trace.Wrap(err, "determining pod and container ID") + } + a.log.DebugContext(ctx, "Found pod and container ID", "pod_id", podID, "container_id", containerID) + + pod, err := a.getPodForID(ctx, podID) + if err != nil { + return KubernetesAttestation{}, trace.Wrap(err, "finding pod by ID") + } + a.log.DebugContext(ctx, "Found pod", "pod_name", pod.Name) + + att := KubernetesAttestation{ + Attested: true, + Namespace: pod.Namespace, + ServiceAccount: pod.Spec.ServiceAccountName, + PodName: pod.Name, + PodUID: string(pod.UID), + Labels: pod.Labels, + } + a.log.DebugContext(ctx, "Finished Kubernetes workload attestation", "attestation", att) + return att, nil +} + +// getContainerAndPodID retrieves the container ID and pod ID for the provided +// PID. +func (a *KubernetesAttestor) getContainerAndPodID(pid int) (podID string, containerID string, err error) { + info, err := mount.ParseMountInfo( + path.Join(a.rootPath, "/proc", strconv.Itoa(pid), "mountinfo"), + ) + if err != nil { + return "", "", trace.Wrap( + err, "parsing mountinfo", + ) + } + + // Find the cgroup or cgroupv2 mount + // For cgroup v2, we expect a single mount. But for cgroup v1, there will + // be one mount per subsystem, but regardless, they will all contain the + // same container ID/pod ID. + var cgroupMount mount.MountInfo + for _, m := range info { + if m.FsType == "cgroup" || m.FsType == "cgroup2" { + cgroupMount = m + break + } + } + + podID, containerID, err = mountpointSourceToContainerAndPodID( + cgroupMount.Root, + ) + if err != nil { + return "", "", trace.Wrap( + err, "parsing cgroup mount (root: %q)", cgroupMount.Root, + ) + } + return podID, containerID, nil +} + +var ( + // A container ID is usually a 64 character hex string, so this regex just + // selects for that. + containerIDRegex = regexp.MustCompile(`(?P[[:xdigit:]]{64})`) + // A pod ID is usually a UUID prefaced with "pod". + // There are two main cgroup drivers: + // - systemd , the dashes are replaced with underscores + // - cgroupfs, the dashes are kept. + podIDRegex = regexp.MustCompile(`pod(?P[[:xdigit:]]{8}[_-][[:xdigit:]]{4}[_-][[:xdigit:]]{4}[_-][[:xdigit:]]{4}[_-][[:xdigit:]]{12})`) +) + +// mountpointSourceToContainerAndPodID takes the source of the cgroup mountpoint +// and extracts the container ID and pod ID from it. +// +// Note: this is a fairly naive implementation, we may need to make further +// improvements to account for other distributions of Kubernetes. +func mountpointSourceToContainerAndPodID(source string) (podID string, containerID string, err error) { + // From the mount, we need to extract the container ID and pod ID. + // Unfortunately this process can be a little fragile, as the format of + // the mountpoint varies across Kubernetes implementations. + // There's a collection of real world mountfiles in testdata/mountfile. + + matches := containerIDRegex.FindStringSubmatch(source) + if len(matches) != 2 { + return "", "", trace.BadParameter( + "expected 2 matches searching for container ID but found %d", + len(matches), + ) + } + containerID = matches[1] + if containerID == "" { + return "", "", trace.BadParameter( + "source does not contain container ID", + ) + } + + matches = podIDRegex.FindStringSubmatch(source) + if len(matches) != 2 { + return "", "", trace.BadParameter( + "expected 2 matches searching for pod ID but found %d", + len(matches), + ) + } + podID = matches[1] + if podID == "" { + return "", "", trace.BadParameter( + "source does not contain pod ID", + ) + } + + // When using the `systemd` cgroup driver, the dashes are replaced with + // underscores. So let's correct that. + podID = strings.ReplaceAll(podID, "_", "-") + + return podID, containerID, nil +} + +// getPodForID retrieves the pod information for the provided pod ID. +// https://github.com/kubernetes/kubernetes/blob/master/pkg/kubelet/server/server.go#L371 +func (a *KubernetesAttestor) getPodForID(ctx context.Context, podID string) (*v1.Pod, error) { + pods, err := a.kubeletClient.ListAllPods(ctx) + if err != nil { + return nil, trace.Wrap(err, "listing all pods") + } + for _, pod := range pods.Items { + if string(pod.UID) == podID { + return &pod, nil + } + } + return nil, trace.NotFound("pod %q not found", podID) +} + +// kubeletClient is a HTTP client for the Kubelet API +type kubeletClient struct { + cfg KubeletClientConfig + getEnv func(string) string +} + +func newKubeletClient(cfg KubeletClientConfig) *kubeletClient { + return &kubeletClient{ + cfg: cfg, + getEnv: os.Getenv, + } +} + +type roundTripperFn func(req *http.Request) (*http.Response, error) + +func (f roundTripperFn) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func (c *kubeletClient) httpClient() (url.URL, *http.Client, error) { + host := c.getEnv(nodeNameEnv) + + if c.cfg.ReadOnlyPort != 0 { + return url.URL{ + Scheme: "http", + Host: net.JoinHostPort(host, strconv.Itoa(c.cfg.ReadOnlyPort)), + }, &http.Client{}, nil + } + + port := cmp.Or(c.cfg.SecurePort, defaultSecurePort) + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{}, + } + + switch { + case c.cfg.SkipVerify: + transport.TLSClientConfig.InsecureSkipVerify = true + default: + caPath := cmp.Or(c.cfg.CAPath, defaultCAPath) + certPool := x509.NewCertPool() + caPEM, err := os.ReadFile(caPath) + if err != nil { + return url.URL{}, nil, trace.Wrap(err, "reading CA file %q", caPath) + } + if !certPool.AppendCertsFromPEM(caPEM) { + return url.URL{}, nil, trace.BadParameter("failed to append CA cert from %q", caPath) + } + transport.TLSClientConfig.RootCAs = certPool + } + + client := &http.Client{ + Transport: transport, + // 10 seconds is fairly generous given that we're expecting to talk to + // kubelet on the same physical machine. + Timeout: 10 * time.Second, + } + + switch { + case c.cfg.Anonymous: + // Nothing to do + case c.cfg.TokenPath != "": + fallthrough + default: + tokenPath := cmp.Or(c.cfg.TokenPath, defaultServiceAccountTokenPath) + token, err := os.ReadFile(tokenPath) + if err != nil { + return url.URL{}, nil, trace.Wrap(err, "reading token file %q", tokenPath) + } + client.Transport = roundTripperFn(func(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + return transport.RoundTrip(req) + }) + } + + return url.URL{ + Scheme: "https", + Host: net.JoinHostPort(host, strconv.Itoa(port)), + }, client, nil +} + +func (c *kubeletClient) ListAllPods(ctx context.Context) (*v1.PodList, error) { + reqUrl, client, err := c.httpClient() + if err != nil { + return nil, trace.Wrap(err, "creating HTTP client") + } + reqUrl.Path = "/pods" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl.String(), nil) + if err != nil { + return nil, trace.Wrap(err, "creating request") + } + + res, err := client.Do(req) + if err != nil { + return nil, trace.Wrap(err, "performing request") + } + defer res.Body.Close() + + out := &v1.PodList{} + if err := json.NewDecoder(res.Body).Decode(out); err != nil { + return nil, trace.Wrap(err, "decoding response") + } + return out, nil +} diff --git a/lib/tbot/spiffe/workloadattest/kubernetes_unix_test.go b/lib/tbot/spiffe/workloadattest/kubernetes_unix_test.go new file mode 100644 index 0000000000000..79704cb775cf8 --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/kubernetes_unix_test.go @@ -0,0 +1,178 @@ +//go:build unix + +/* + * Teleport + * Copyright (C) 2024 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 . + */ + +package workloadattest + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/gravitational/teleport/lib/utils" +) + +func TestKubernetesAttestor_getContainerAndPodID(t *testing.T) { + log := utils.NewSlogLoggerForTests() + tests := []struct { + name string + wantPodID string + wantContainerID string + }{ + { + name: "k8s-real-docker-desktop", + wantPodID: "941f292f-a62d-48ab-b9a8-eec84d87b928", + wantContainerID: "3f79e718744418736d0f6b9958e08d44e969c6577068c33de1cc400d35aacec8", + }, + { + name: "k8s-real-orbstack", + wantPodID: "36827f77-691f-45aa-a470-0989cf3749c4", + wantContainerID: "64dd9bf5199ff782835247cb072e4842dc3d0135ef02f6498cb6bb6f37a320d2", + }, + { + name: "k8s-real-k3s-ubuntu-v1.28.6+k3s2", + wantPodID: "fecd2321-17b5-49b9-9f75-8c5be777fbfb", + wantContainerID: "397529d07efebd566f15dbc7e8af9f3ef586033f5e753adfa96b2bf730102c64", + }, + { + name: "k8s-real-gcp-v1.29.5-gke.1091002", + wantPodID: "61c266b0-6f75-4490-8d92-3c9ae4d02787", + wantContainerID: "9da25af0b548c8c60aa60f77f299ba727bf72d58248bd7528eb5390ffcce555a", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "proc", "1234"), 0755)) + require.NoError(t, utils.CopyFile( + filepath.Join("testdata", "mountfile", tt.name), + filepath.Join(tempDir, "proc", "1234", "mountinfo"), + 0755), + ) + attestor := &KubernetesAttestor{ + rootPath: tempDir, + log: log, + } + gotPodID, gotContainerID, err := attestor.getContainerAndPodID(1234) + assert.NoError(t, err) + assert.Equal(t, tt.wantPodID, gotPodID) + assert.Equal(t, tt.wantContainerID, gotContainerID) + }) + } +} + +func TestKubernetesAttestor_Attest(t *testing.T) { + t.Parallel() + log := utils.NewSlogLoggerForTests() + ctx := context.Background() + + mockToken := "FOOBARBUZZ" + mockPID := 1234 + // Value from k8s-real-gcp-v1.29.5-gke.1091002 + mockPodID := "61c266b0-6f75-4490-8d92-3c9ae4d02787" + + // Setup mock Kubelet Secure API + mockKubeletAPI := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/pods" { + http.NotFound(w, req) + return + } + out := v1.PodList{ + Items: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-pod", + Namespace: "default", + UID: types.UID(mockPodID), + Labels: map[string]string{ + "my-label": "my-label-value", + }, + }, + Spec: v1.PodSpec{ + ServiceAccountName: "my-service-account", + }, + }, + }, + } + w.WriteHeader(200) + assert.NoError(t, json.NewEncoder(w).Encode(out)) + })) + t.Cleanup(mockKubeletAPI.Close) + kubeletAddr := mockKubeletAPI.Listener.Addr().String() + host, port, err := net.SplitHostPort(kubeletAddr) + require.NoError(t, err) + portInt, err := strconv.Atoi(port) + require.NoError(t, err) + + // Setup mock filesystem + tmpDir := t.TempDir() + tokenPath := filepath.Join(tmpDir, "token") + require.NoError(t, os.WriteFile(tokenPath, []byte(mockToken), 0644)) + procPath := filepath.Join(tmpDir, "proc") + procPIDPath := filepath.Join(procPath, strconv.Itoa(mockPID)) + pidMountInfoPath := filepath.Join(procPIDPath, "mountinfo") + require.NoError(t, os.MkdirAll(procPIDPath, 0755)) + require.NoError(t, utils.CopyFile( + filepath.Join("testdata", "mountfile", "k8s-real-gcp-v1.29.5-gke.1091002"), + pidMountInfoPath, + 0755), + ) + + // Setup Attestor for mocks + attestor := NewKubernetesAttestor(KubernetesAttestorConfig{ + Enabled: true, + Kubelet: KubeletClientConfig{ + TokenPath: tokenPath, + SkipVerify: true, + SecurePort: portInt, + }, + }, log) + attestor.rootPath = tmpDir + attestor.kubeletClient.getEnv = func(s string) string { + env := map[string]string{ + "TELEPORT_NODE_NAME": host, + } + return env[s] + } + + att, err := attestor.Attest(ctx, mockPID) + assert.NoError(t, err) + assert.Equal(t, KubernetesAttestation{ + Attested: true, + ServiceAccount: "my-service-account", + Namespace: "default", + PodName: "my-pod", + PodUID: mockPodID, + Labels: map[string]string{ + "my-label": "my-label-value", + }, + }, att) +} diff --git a/lib/tbot/spiffe/workloadattest/kubernetes_windows.go b/lib/tbot/spiffe/workloadattest/kubernetes_windows.go new file mode 100644 index 0000000000000..27b11b13227ca --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/kubernetes_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +/* + * Teleport + * Copyright (C) 2024 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 . + */ + +package workloadattest + +import ( + "context" + "log/slog" + + "github.com/gravitational/trace" +) + +// WindowsKubernetesAttestor is the windows stub for KubernetesAttestor. +type WindowsKubernetesAttestor struct { +} + +func (a WindowsKubernetesAttestor) Attest(_ context.Context, _ int) (KubernetesAttestation, error) { + return KubernetesAttestation{}, trace.NotImplemented("kubernetes attestation is not supported on windows") +} + +// NewKubernetesAttestor creates a new KubernetesAttestor. +func NewKubernetesAttestor(_ KubernetesAttestorConfig, _ *slog.Logger) *WindowsKubernetesAttestor { + return &WindowsKubernetesAttestor{} +} diff --git a/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-docker-desktop b/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-docker-desktop new file mode 100644 index 0000000000000..4b18eb32c96ef --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-docker-desktop @@ -0,0 +1,22 @@ +752 518 0:203 / / rw,relatime master:65 - overlay overlay rw,lowerdir=/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/140/fs:/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/134/fs,upperdir=/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/141/fs,workdir=/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/141/work +753 752 0:205 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw +754 752 0:206 / /dev rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +755 754 0:207 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=666 +756 752 0:201 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro +757 756 0:31 /../../pod941f292f-a62d-48ab-b9a8-eec84d87b928/3f79e718744418736d0f6b9958e08d44e969c6577068c33de1cc400d35aacec8 /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw +758 754 0:197 / /dev/mqueue rw,nosuid,nodev,noexec,relatime - mqueue mqueue rw +759 754 0:196 / /dev/shm rw,nosuid,nodev,noexec,relatime - tmpfs shm rw,size=65536k +760 754 254:1 /kubelet/pods/941f292f-a62d-48ab-b9a8-eec84d87b928/containers/ubuntu/0a0e971b /dev/termination-log rw,relatime - ext4 /dev/vda1 rw,discard +761 752 254:1 /docker/containers/4bdc92c994044b7998d9288ce0dfd191bff269fe24040c7966381a32ee2566c6/resolv.conf /etc/resolv.conf rw,relatime - ext4 /dev/vda1 rw,discard +762 752 254:1 /docker/containers/4bdc92c994044b7998d9288ce0dfd191bff269fe24040c7966381a32ee2566c6/hostname /etc/hostname rw,relatime - ext4 /dev/vda1 rw,discard +763 752 254:1 /kubelet/pods/941f292f-a62d-48ab-b9a8-eec84d87b928/etc-hosts /etc/hosts rw,relatime - ext4 /dev/vda1 rw,discard +764 752 0:193 / /run/secrets/kubernetes.io/serviceaccount ro,relatime - tmpfs tmpfs rw,size=7926524k +519 753 0:205 /bus /proc/bus ro,nosuid,nodev,noexec,relatime - proc proc rw +536 753 0:205 /fs /proc/fs ro,nosuid,nodev,noexec,relatime - proc proc rw +537 753 0:205 /irq /proc/irq ro,nosuid,nodev,noexec,relatime - proc proc rw +538 753 0:205 /sys /proc/sys ro,nosuid,nodev,noexec,relatime - proc proc rw +539 753 0:205 /sysrq-trigger /proc/sysrq-trigger ro,nosuid,nodev,noexec,relatime - proc proc rw +540 753 0:206 /null /proc/kcore rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +541 753 0:206 /null /proc/keys rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +567 753 0:206 /null /proc/timer_list rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +582 756 0:208 / /sys/firmware ro,relatime - tmpfs tmpfs ro \ No newline at end of file diff --git a/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-gcp-v1.29.5-gke.1091002 b/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-gcp-v1.29.5-gke.1091002 new file mode 100644 index 0000000000000..308bb7dce070d --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-gcp-v1.29.5-gke.1091002 @@ -0,0 +1,24 @@ +2649 2422 0:419 / / rw,relatime master:943 - overlay overlay rw,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/434/fs,upperdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/448/fs,workdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/448/work +2650 2649 0:421 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw +2651 2649 0:422 / /dev rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +2652 2651 0:423 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=666 +2653 2651 0:411 / /dev/mqueue rw,nosuid,nodev,noexec,relatime - mqueue mqueue rw +2654 2649 0:415 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro +2655 2654 0:25 /../../kubepods-besteffort-pod61c266b0_6f75_4490_8d92_3c9ae4d02787.slice/cri-containerd-9da25af0b548c8c60aa60f77f299ba727bf72d58248bd7528eb5390ffcce555a.scope /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw +2656 2649 8:1 /var/lib/kubelet/pods/61c266b0-6f75-4490-8d92-3c9ae4d02787/etc-hosts /etc/hosts rw,relatime - ext4 /dev/sda1 rw,commit=30 +2657 2651 8:1 /var/lib/kubelet/pods/61c266b0-6f75-4490-8d92-3c9ae4d02787/containers/ubuntu/32ca55fa /dev/termination-log rw,relatime - ext4 /dev/sda1 rw,commit=30 +2658 2649 8:1 /var/lib/containerd/io.containerd.grpc.v1.cri/sandboxes/569976599cee4242e02a57be87909f6f08bc6615e346d57a1f07dfab7239c4ff/hostname /etc/hostname rw,nosuid,nodev,relatime - ext4 /dev/sda1 rw,commit=30 +2659 2649 8:1 /var/lib/containerd/io.containerd.grpc.v1.cri/sandboxes/569976599cee4242e02a57be87909f6f08bc6615e346d57a1f07dfab7239c4ff/resolv.conf /etc/resolv.conf rw,nosuid,nodev,relatime - ext4 /dev/sda1 rw,commit=30 +2660 2651 0:408 / /dev/shm rw,nosuid,nodev,noexec,relatime - tmpfs shm rw,size=65536k +2661 2649 0:407 / /run/secrets/kubernetes.io/serviceaccount ro,relatime - tmpfs tmpfs rw,size=2873312k +2423 2650 0:421 /bus /proc/bus ro,nosuid,nodev,noexec,relatime - proc proc rw +2424 2650 0:421 /fs /proc/fs ro,nosuid,nodev,noexec,relatime - proc proc rw +2425 2650 0:421 /irq /proc/irq ro,nosuid,nodev,noexec,relatime - proc proc rw +2426 2650 0:421 /sys /proc/sys ro,nosuid,nodev,noexec,relatime - proc proc rw +2427 2650 0:421 /sysrq-trigger /proc/sysrq-trigger ro,nosuid,nodev,noexec,relatime - proc proc rw +2428 2650 0:424 / /proc/acpi ro,relatime - tmpfs tmpfs ro +2429 2650 0:422 /null /proc/kcore rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +2430 2650 0:422 /null /proc/keys rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +2431 2650 0:422 /null /proc/timer_list rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +2432 2650 0:425 / /proc/scsi ro,relatime - tmpfs tmpfs ro +2433 2654 0:426 / /sys/firmware ro,relatime - tmpfs tmpfs ro \ No newline at end of file diff --git a/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-k3s-ubuntu-v1.28.6+k3s2 b/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-k3s-ubuntu-v1.28.6+k3s2 new file mode 100644 index 0000000000000..b7987523080fc --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-k3s-ubuntu-v1.28.6+k3s2 @@ -0,0 +1,27 @@ +3029 2813 0:398 / / rw,relatime master:1634 - overlay overlay rw,lowerdir=/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/668/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/667/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/666/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/665/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/664/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/663/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/662/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/661/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/660/fs:/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/659/fs,upperdir=/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1104/fs,workdir=/ssd/k3s/agent/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/1104/work,nouserxattr +2709 3029 0:418 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw +3030 3029 0:419 / /dev rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755,inode64 +2697 3030 0:420 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=666 +3031 3030 0:264 / /dev/mqueue rw,nosuid,nodev,noexec,relatime - mqueue mqueue rw +3032 3029 0:272 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro +3033 3032 0:29 /../../kubepods-besteffort-podfecd2321_17b5_49b9_9f75_8c5be777fbfb.slice/cri-containerd-397529d07efebd566f15dbc7e8af9f3ef586033f5e753adfa96b2bf730102c64.scope /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw +3034 3029 0:39 /kubelet/pods/fecd2321-17b5-49b9-9f75-8c5be777fbfb/etc-hosts /etc/hosts rw,noatime - zfs ssd rw,xattr,posixacl,casesensitive +3035 3030 0:39 /kubelet/pods/fecd2321-17b5-49b9-9f75-8c5be777fbfb/containers/influxdb2/0b3cd38e /dev/termination-log rw,noatime - zfs ssd rw,xattr,posixacl,casesensitive +3036 3029 0:39 /k3s/agent/containerd/io.containerd.grpc.v1.cri/sandboxes/dfb5782367e348095bcac6bc52e3f2428dbfdc4704c1f94897b7095831de0f29/hostname /etc/hostname rw,noatime - zfs ssd rw,xattr,posixacl,casesensitive +3041 3029 0:39 /k3s/agent/containerd/io.containerd.grpc.v1.cri/sandboxes/dfb5782367e348095bcac6bc52e3f2428dbfdc4704c1f94897b7095831de0f29/resolv.conf /etc/resolv.conf rw,noatime - zfs ssd rw,xattr,posixacl,casesensitive +3056 3030 0:172 / /dev/shm rw,nosuid,nodev,noexec,relatime - tmpfs shm rw,size=65536k,inode64 +3057 3029 0:39 /k3s/agent/containerd/io.containerd.grpc.v1.cri/containers/397529d07efebd566f15dbc7e8af9f3ef586033f5e753adfa96b2bf730102c64/volumes/e8d71e077278681842902223965b71754c3cead29651ea453b1022a0715a64ae /etc/influxdb2 rw,noatime - zfs ssd rw,xattr,posixacl,casesensitive +3067 3029 0:39 /k3s/storage/pvc-fe4e77b2-8676-40b8-acca-bf0a7208ee37_influxdb_influxdb-influxdb2 /var/lib/influxdb2 rw,noatime - zfs ssd rw,xattr,posixacl,casesensitive +3068 3029 0:124 / /run/secrets/kubernetes.io/serviceaccount ro,relatime - tmpfs tmpfs rw,size=49238972k,inode64 +3362 2709 0:418 /bus /proc/bus ro,nosuid,nodev,noexec,relatime - proc proc rw +3364 2709 0:418 /fs /proc/fs ro,nosuid,nodev,noexec,relatime - proc proc rw +3365 2709 0:418 /irq /proc/irq ro,nosuid,nodev,noexec,relatime - proc proc rw +3381 2709 0:418 /sys /proc/sys ro,nosuid,nodev,noexec,relatime - proc proc rw +3382 2709 0:418 /sysrq-trigger /proc/sysrq-trigger ro,nosuid,nodev,noexec,relatime - proc proc rw +3383 2709 0:431 / /proc/asound ro,relatime - tmpfs tmpfs ro,inode64 +3384 2709 0:432 / /proc/acpi ro,relatime - tmpfs tmpfs ro,inode64 +3385 2709 0:419 /null /proc/kcore rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755,inode64 +3386 2709 0:419 /null /proc/keys rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755,inode64 +3387 2709 0:419 /null /proc/timer_list rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755,inode64 +3388 2709 0:433 / /proc/scsi ro,relatime - tmpfs tmpfs ro,inode64 +3389 3032 0:434 / /sys/firmware ro,relatime - tmpfs tmpfs ro,inode64 \ No newline at end of file diff --git a/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-orbstack b/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-orbstack new file mode 100644 index 0000000000000..7aff934e8cde8 --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/testdata/mountfile/k8s-real-orbstack @@ -0,0 +1,24 @@ +705 559 0:163 / / ro,relatime master:126 - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/l/Z45N3JIQYJQ54CBI7H3XMKVR5I:/var/lib/docker/overlay2/l/7KUPS4PU3O5IJL4EIRN7FMAOYD:/var/lib/docker/overlay2/l/ETEFQDOQ5PLMGLRYOA5RFON3OS:/var/lib/docker/overlay2/l/2B54A5B3XLE76YZCPIVK6UA7RI:/var/lib/docker/overlay2/l/KJGEGGJV7KZFUYQL46H7NCKSGR:/var/lib/docker/overlay2/l/UTJK53EUHIMBZO4NRBU7FR4KZ7:/var/lib/docker/overlay2/l/KHWFISJTIZFY3EZO27BU32LI6L:/var/lib/docker/overlay2/l/CD7NUI35TQGZLK6OY625DPG75E:/var/lib/docker/overlay2/l/MTGDF36JSTP6LT4N5SJM7JQVZF:/var/lib/docker/overlay2/l/56576N3DLXLKEFS4BVEDLETXGY:/var/lib/docker/overlay2/l/TEJQCUGT2ZHWVW65FILDOZNO3Y:/var/lib/docker/overlay2/l/ESKTCIGN4JBSHMNLLDE5UWBKGR:/var/lib/docker/overlay2/l/K4SEMZCPJSC5OOK4HNSTJ5EADF:/var/lib/docker/overlay2/l/OFXCSYIR4UIHYMYJQYQNAP2IVZ:/var/lib/docker/overlay2/l/HHM5GPXHH5O7FGYKI3DAF2TIE3:/var/lib/docker/overlay2/l/6EQX2D3HPZETMHLYONEJBT57GT:/var/lib/docker/overlay2/l/IB4VF3AIY6HVGYQ4CLLZD2HEMO:/var/lib/docker/overlay2/l/4SXNG5ILJTM4EGDS2DH2YODOHB:/var/lib/docker/overlay2/l/DA2FKFC5NFV4LTLQ7KXZJ7QH5N:/var/lib/docker/overlay2/l/W54AWW6M7O6OLVIFMHMPXIDZG5,upperdir=/var/lib/docker/overlay2/a5af8a9c1badd3ba20f186c2da75c1bd0a6a832227388d4936ce9e7729fbeec4/diff,workdir=/var/lib/docker/overlay2/a5af8a9c1badd3ba20f186c2da75c1bd0a6a832227388d4936ce9e7729fbeec4/work +706 705 0:169 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw +707 705 0:170 / /dev rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +708 707 0:171 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=666 +709 705 0:138 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro +710 709 0:31 /../../pod36827f77-691f-45aa-a470-0989cf3749c4/64dd9bf5199ff782835247cb072e4842dc3d0135ef02f6498cb6bb6f37a320d2 /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw,nsdelegate +711 707 0:134 / /dev/mqueue rw,nosuid,nodev,noexec,relatime - mqueue mqueue rw +712 707 0:132 / /dev/shm rw,nosuid,nodev,noexec,relatime - tmpfs shm rw,size=65536k +713 707 0:36 /k8s/default/kubelet/pods/36827f77-691f-45aa-a470-0989cf3749c4/containers/teleport/d1a0d450 /dev/termination-log rw,noatime - btrfs /dev/vdb1 rw,nodatasum,nodatacow,ssd,discard,space_cache=v2,subvolid=5,subvol=/ +714 705 0:36 /k8s/default/kubelet/pods/36827f77-691f-45aa-a470-0989cf3749c4/volumes/kubernetes.io~configmap/config /etc/teleport ro,noatime - btrfs /dev/vdb1 rw,nodatasum,nodatacow,ssd,discard,space_cache=v2,subvolid=5,subvol=/ +715 705 0:119 / /etc/teleport-secrets ro,relatime - tmpfs tmpfs rw,size=8121144k +716 705 0:36 /docker/containers/1927b688b6cc740a4d73f211b67b7573503ff3dd401d3e8d43dd449d032ce8d2/resolv.conf /etc/resolv.conf ro,noatime - btrfs /dev/vdb1 rw,nodatasum,nodatacow,ssd,discard,space_cache=v2,subvolid=5,subvol=/ +717 705 0:36 /docker/containers/1927b688b6cc740a4d73f211b67b7573503ff3dd401d3e8d43dd449d032ce8d2/hostname /etc/hostname ro,noatime - btrfs /dev/vdb1 rw,nodatasum,nodatacow,ssd,discard,space_cache=v2,subvolid=5,subvol=/ +718 705 0:36 /k8s/default/kubelet/pods/36827f77-691f-45aa-a470-0989cf3749c4/etc-hosts /etc/hosts rw,noatime - btrfs /dev/vdb1 rw,nodatasum,nodatacow,ssd,discard,space_cache=v2,subvolid=5,subvol=/ +719 705 0:36 /k8s/default/kubelet/pods/36827f77-691f-45aa-a470-0989cf3749c4/volumes/kubernetes.io~empty-dir/data /var/lib/teleport rw,noatime - btrfs /dev/vdb1 rw,nodatasum,nodatacow,ssd,discard,space_cache=v2,subvolid=5,subvol=/ +720 705 0:114 / /var/run/secrets/kubernetes.io/serviceaccount ro,relatime - tmpfs tmpfs rw,size=8121144k +535 706 0:169 /bus /proc/bus ro,nosuid,nodev,noexec,relatime - proc proc rw +541 706 0:169 /fs /proc/fs ro,nosuid,nodev,noexec,relatime - proc proc rw +553 706 0:169 /irq /proc/irq ro,nosuid,nodev,noexec,relatime - proc proc rw +554 706 0:169 /sys /proc/sys ro,nosuid,nodev,noexec,relatime - proc proc rw +556 706 0:169 /sysrq-trigger /proc/sysrq-trigger ro,nosuid,nodev,noexec,relatime - proc proc rw +562 706 0:170 /null /proc/keys rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +566 706 0:170 /null /proc/timer_list rw,nosuid - tmpfs tmpfs rw,size=65536k,mode=755 +567 709 0:167 / /sys/firmware ro,relatime - tmpfs tmpfs ro \ No newline at end of file diff --git a/lib/tbot/spiffe/workloadattest/unix.go b/lib/tbot/spiffe/workloadattest/unix.go new file mode 100644 index 0000000000000..2f67fd7f6bad2 --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/unix.go @@ -0,0 +1,122 @@ +/* + * Teleport + * Copyright (C) 2024 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 . + */ + +package workloadattest + +import ( + "context" + "log/slog" + + "github.com/gravitational/trace" + "github.com/shirou/gopsutil/v4/process" +) + +// UnixAttestation holds the Unix process information retrieved from the +// workload attestation process. +type UnixAttestation struct { + // Attested is true if the PID was successfully attested to a Unix + // process. This indicates the validity of the rest of the fields. + Attested bool + // PID is the process ID of the attested process. + PID int + // UID is the primary user ID of the attested process. + UID int + // GID is the primary group ID of the attested process. + GID int +} + +// LogValue implements slog.LogValue to provide a nicely formatted set of +// log keys for a given attestation. +func (a UnixAttestation) LogValue() slog.Value { + values := []slog.Attr{ + slog.Bool("attested", a.Attested), + } + if a.Attested { + values = append(values, + slog.Int("uid", a.UID), + slog.Int("pid", a.PID), + slog.Int("gid", a.GID), + ) + } + return slog.GroupValue(values...) +} + +// UnixAttestor attests a process id to a Unix process. +type UnixAttestor struct { +} + +// NewUnixAttestor returns a new UnixAttestor. +func NewUnixAttestor() *UnixAttestor { + return &UnixAttestor{} +} + +// Attest attests a process id to a Unix process. +func (a *UnixAttestor) Attest(ctx context.Context, pid int) (UnixAttestation, error) { + p, err := process.NewProcessWithContext(ctx, int32(pid)) + if err != nil { + return UnixAttestation{}, trace.Wrap(err, "getting process") + } + + att := UnixAttestation{ + Attested: true, + PID: pid, + } + // On Linux: + // Real, effective, saved, and file system GIDs + // On Darwin: + // Effective, effective, saved GIDs + gids, err := p.Gids() + if err != nil { + return UnixAttestation{}, trace.Wrap(err, "getting gids") + } + // We generally want to select the effective GID. + switch len(gids) { + case 0: + // error as none returned + return UnixAttestation{}, trace.BadParameter("no gids returned") + case 1: + // Only one GID - this is unusual but let's take it. + att.GID = int(gids[0]) + default: + // Take the index 1 entry as this is effective + att.GID = int(gids[1]) + } + + // On Linux: + // Real, effective, saved set, and file system UIDs + // On Darwin: + // Effective + uids, err := p.Uids() + if err != nil { + return UnixAttestation{}, trace.Wrap(err, "getting uids") + } + // We generally want to select the effective GID. + switch len(uids) { + case 0: + // error as none returned + return UnixAttestation{}, trace.BadParameter("no uids returned") + case 1: + // Only one UID, we expect this on Darwin to be the Effective UID + att.UID = int(uids[0]) + default: + // Take the index 1 entry as this is Effective UID on Linux + att.UID = int(uids[1]) + } + + return att, nil +} diff --git a/lib/tbot/spiffe/workloadattest/unix_test.go b/lib/tbot/spiffe/workloadattest/unix_test.go new file mode 100644 index 0000000000000..667fdcffb2634 --- /dev/null +++ b/lib/tbot/spiffe/workloadattest/unix_test.go @@ -0,0 +1,46 @@ +/* + * Teleport + * Copyright (C) 2024 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 . + */ + +package workloadattest + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnixAttestor_Attest(t *testing.T) { + t.Parallel() + ctx := context.Background() + + pid := os.Getpid() + uid := os.Getuid() + gid := os.Getgid() + + attestor := NewUnixAttestor() + att, err := attestor.Attest(ctx, pid) + require.NoError(t, err) + require.Equal(t, UnixAttestation{ + Attested: true, + PID: pid, + UID: uid, + GID: gid, + }, att) +}