From 1fd4373b357a10897d7cd4e92b9761894342c88f Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:06:31 +1000 Subject: [PATCH] fix: verify caller-supplied role ARNs against the stored canonical ARN (#972) --- go.mod | 28 +++---- go.sum | 60 +++++++-------- spinifex/handlers/eks/delete_cluster_test.go | 2 +- spinifex/handlers/eks/nodegroup.go | 23 ++++-- .../eks/nodegroup_instance_profile_test.go | 76 +++++++++++++++++-- spinifex/handlers/eks/nodegroup_test.go | 2 +- .../sts/assume_role_with_web_identity.go | 12 +-- .../sts/assume_role_with_web_identity_test.go | 27 +++++++ spinifex/handlers/sts/role_arn.go | 27 ++++--- 9 files changed, 184 insertions(+), 73 deletions(-) diff --git a/go.mod b/go.mod index c4a9145bd..9e87d7576 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/klauspost/cpuid/v2 v2.4.0 github.com/miekg/dns v1.1.73 - github.com/mulgadc/bluebottle v1.18.1-0.20260827045410-e7d5cf022748 + github.com/mulgadc/bluebottle v1.18.1-0.20260901051501-b00bc7828992 github.com/mulgadc/northstar v1.18.0 github.com/mulgadc/predastore v1.18.1-0.20260831064140-ff6a1dc42441 github.com/mulgadc/viperblock v1.18.0 @@ -119,7 +119,7 @@ require ( github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-metrics v0.5.4 // indirect @@ -184,14 +184,14 @@ require ( go.etcd.io/bbolt v1.4.3 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/bridges/otelslog v0.20.0 // indirect - go.opentelemetry.io/contrib/instrumentation/runtime v0.70.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect - go.opentelemetry.io/otel/log v0.21.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.21.0 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 // indirect + go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect + go.opentelemetry.io/otel/log v0.22.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.22.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect @@ -202,10 +202,10 @@ require ( golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/vuln v1.6.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/grpc v1.83.0 // indirect - google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect tags.cncf.io/container-device-interface v1.1.0 // indirect diff --git a/go.sum b/go.sum index f3658133d..12f1f07d5 100644 --- a/go.sum +++ b/go.sum @@ -1105,8 +1105,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9K github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hamba/avro/v2 v2.17.2/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo= github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= @@ -1327,8 +1327,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/mulgadc/bluebottle v1.18.1-0.20260827045410-e7d5cf022748 h1:kvoYwPtGXrX7GTN1aRL9KteP0bteaYNzrSMFQ+A7AmU= -github.com/mulgadc/bluebottle v1.18.1-0.20260827045410-e7d5cf022748/go.mod h1:mvvV4d2w+kGLXxO1ymptNlxqUlNJKB2oAhk6PkpVLj8= +github.com/mulgadc/bluebottle v1.18.1-0.20260901051501-b00bc7828992 h1:ss5/vEr5STIZXBWAZZthlAS6q9i44aOMbaFjZL9ediY= +github.com/mulgadc/bluebottle v1.18.1-0.20260901051501-b00bc7828992/go.mod h1:XBIMI+G2s3wuJNiEdYtlGDvCacw82OXEw50RIQhowYg= github.com/mulgadc/northstar v1.18.0 h1:ksRjIKE6qtrw/ZoYaJh8JRBh+xdnuDw8FsmFLFCiXbg= github.com/mulgadc/northstar v1.18.0/go.mod h1:z6iol77R1vsIQyIBc1JQboUPhSJ6eG4bipzfbZZbLEM= github.com/mulgadc/predastore v1.18.1-0.20260831064140-ff6a1dc42441 h1:5t+iuKpnRWXkKOmh5lLBoziBaWD+H71edWO/O4ceMp8= @@ -1639,8 +1639,8 @@ go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/bridges/otelslog v0.20.0 h1:oEl2Pw/i4OQwhAuda2pAHFAcOMivA+Xa+iTccBfab/g= -go.opentelemetry.io/contrib/bridges/otelslog v0.20.0/go.mod h1:yMSQaiiq5dpfrSJCYLBcqFeJkFFI67seT4ngvx6jfVo= +go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 h1:5sHc4ToTFjfSZCtGAAM6jPunICAmJX73htv372T4ipc= +go.opentelemetry.io/contrib/bridges/otelslog v0.20.1/go.mod h1:oa6kgvyz/3GYW04dohd0++xJIH4xdQY8PAbpeCMaM8M= go.opentelemetry.io/contrib/detectors/gcp v1.28.0/go.mod h1:9BIqH22qyHWAiZxQh0whuJygro59z+nbMVuc7ciiGug= go.opentelemetry.io/contrib/detectors/gcp v1.29.0/go.mod h1:GW2aWZNwR2ZxDLdv8OyC2G8zkRoQBuURgV7RPQgcPoU= go.opentelemetry.io/contrib/detectors/gcp v1.31.0/go.mod h1:tzQL6E1l+iV44YFTkcAeNQqzXUiekSYP9jjJjXwEd00= @@ -1678,8 +1678,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= -go.opentelemetry.io/contrib/instrumentation/runtime v0.70.0 h1:1+WLVYezXA9tkuVzKQri8zgB1cEIVYKUSoYIRjsBiMU= -go.opentelemetry.io/contrib/instrumentation/runtime v0.70.0/go.mod h1:rbAXUUXqQDMxpSnmof4VtcZ+7YpZQEtjXSCIfdvR0Go= +go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 h1:v4KkRLVvE1cWqTJDfZZkTCG+Z4aolsa6RVos0FX7vqE= +go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0/go.mod h1:g/xbuPC0XbgwMdKuyF5sKOUUEsorSkN6APydyFP/H9E= go.opentelemetry.io/otel v0.20.0/go.mod h1:Y3ugLH2oa81t5QO+Lty+zXf8zC9L26ax4Nzoxm/dooo= go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= @@ -1704,21 +1704,21 @@ go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1sp go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0/go.mod h1:SiLZnQS6Qk2eCpvr2CH/XMAOa64TWGXxEZJZCpD2Lmc= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 h1:klTViGcsvLCd1xN3rZzfZ12NslC/OimbmR+k+A006RI= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0/go.mod h1:jRsK04CWmXuY8A0O+wMpSf+t90RHZ53o5Qmxn2PQPfk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 h1:Bu39F5tzJct+f2IZbB8989fwyTps3c8e7EsUQsz+vs8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0/go.mod h1:dJUwod88EsFgYCqrDHaSPzhiY9pBUpt0d85/qSfua7k= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= go.opentelemetry.io/otel/exporters/prometheus v0.57.0/go.mod h1:QpFWz1QxqevfjwzYdbMb4Y1NnlJvqSGwyuU0B4iuc9c= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= -go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA= -go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g= +go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0= +go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc= go.opentelemetry.io/otel/metric v0.20.0/go.mod h1:598I5tYlH1vzBjn+BTuhzTCSb/9debfNp6R3s7Pr1eU= go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= @@ -1769,10 +1769,10 @@ go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/log v0.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc= -go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo= -go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ= -go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE= +go.opentelemetry.io/otel/sdk/log v0.22.0 h1:PRL+s6P63XT4E/bheEflopPUpVxuvANqZwtt89yhoGk= +go.opentelemetry.io/otel/sdk/log v0.22.0/go.mod h1:JNp0sBELrjCTcu5W3GzABVypeU6vDJjBS+X0JISuz+g= +go.opentelemetry.io/otel/sdk/log/logtest v0.22.0 h1:infPnfNrhCNgOUZRs3gWUg8vhoBUHihq02gwK05gzlg= +go.opentelemetry.io/otel/sdk/log/logtest v0.22.0/go.mod h1:gkQZA3z15Bv3KU9vigBTi8dFechSozRP7v94X4VZv+s= go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg= go.opentelemetry.io/otel/sdk/metric v1.29.0/go.mod h1:6zZLdCl2fkauYoZIOn/soQIDSWFmNSRcICarHfuhNJQ= go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y= @@ -2849,8 +2849,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go. google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= -google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= -google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA= google.golang.org/genproto/googleapis/bytestream v0.0.0-20231012201019-e917dd12ba7a/go.mod h1:+34luvCflYKiKylNwGJfn9cFBbcL/WrkciMmDmsTQ/A= google.golang.org/genproto/googleapis/bytestream v0.0.0-20231030173426-d783a09b4405/go.mod h1:GRUCuLdzVqZte8+Dl/D4N25yLzcGqqWaYkeVOwulFqw= @@ -3031,8 +3031,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= @@ -3126,8 +3126,8 @@ google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y= google.golang.org/grpc/examples v0.0.0-20201112215255-90f1b3ee835b/go.mod h1:IBqQ7wSUJ2Ep09a8rMWFsg4fmI2r38zwsq8a0GgxXpM= @@ -3171,8 +3171,8 @@ google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/spinifex/handlers/eks/delete_cluster_test.go b/spinifex/handlers/eks/delete_cluster_test.go index dbe1abf45..3bb429450 100644 --- a/spinifex/handlers/eks/delete_cluster_test.go +++ b/spinifex/handlers/eks/delete_cluster_test.go @@ -89,7 +89,7 @@ func newEKSServiceFixture(t *testing.T) *eksServiceFixture { VPCSG: sg, VPCK3s: vpc, VPCSubnet: fakeSubnetResolver{}, - IAM: newFakeEnsurer(), + IAM: newEnsurerWithRole("eks-node", fixtureNodeRoleARN), NLB: nlb, Instance: inst, Image: ami, diff --git a/spinifex/handlers/eks/nodegroup.go b/spinifex/handlers/eks/nodegroup.go index 020604729..93d767e5d 100644 --- a/spinifex/handlers/eks/nodegroup.go +++ b/spinifex/handlers/eks/nodegroup.go @@ -706,15 +706,26 @@ func gatewayHostIP(gatewayURL string) string { // provider, mirroring the implicit instance profile real EKS creates for a node // role. Idempotent: concurrent worker launches converge on the same profile. func (s *EKSServiceImpl) ensureNodeInstanceProfile(accountID, nodeRoleARN string) (string, error) { - roleAccount, roleName, err := auth.ParseRoleARN(nodeRoleARN) + // Resolution compares the supplied ARN against the stored one, so an invented + // path cannot bind a profile to the role its trailing name happens to match. + _, roleName, err := auth.ResolveRoleARN(nodeRoleARN, func(account, name string) (string, error) { + // The profile is created under the caller's account, so a role from + // another account would silently bind a name that account does not own. + if account != accountID { + return "", fmt.Errorf("is not in account %s", accountID) + } + out, gerr := s.iamEnsurer().GetRole(account, &iam.GetRoleInput{RoleName: aws.String(name)}) + if gerr != nil { + return "", gerr + } + if out == nil || out.Role == nil { + return "", nil + } + return aws.StringValue(out.Role.Arn), nil + }) if err != nil { return "", fmt.Errorf("node role ARN %q: %w", nodeRoleARN, err) } - // The profile is created under the caller's account, so a role from another - // account would silently bind a name that account does not own. - if roleAccount != accountID { - return "", fmt.Errorf("node role ARN %q is not in account %s", nodeRoleARN, accountID) - } profileName := roleName out, err := s.iamEnsurer().GetInstanceProfile(accountID, &iam.GetInstanceProfileInput{ diff --git a/spinifex/handlers/eks/nodegroup_instance_profile_test.go b/spinifex/handlers/eks/nodegroup_instance_profile_test.go index dfa6bc7fe..78e10692a 100644 --- a/spinifex/handlers/eks/nodegroup_instance_profile_test.go +++ b/spinifex/handlers/eks/nodegroup_instance_profile_test.go @@ -6,6 +6,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/bluebottle/pkg/auth" "github.com/mulgadc/spinifex/spinifex/awserrors" iammock "github.com/mulgadc/spinifex/spinifex/handlers/iam/mock" "github.com/stretchr/testify/assert" @@ -20,8 +21,20 @@ func newFakeEnsurer() *iammock.SystemInstanceRoleEnsurer { const testNodeRoleARN = "arn:aws:iam::000000000001:role/eks-quickstart-node-role" -func TestEnsureNodeInstanceProfile_CreatesAndAttaches(t *testing.T) { +// fixtureNodeRoleARN is the node role the shared service fixture's nodegroups +// launch with; resolution requires it to exist in the mock's IAM. +const fixtureNodeRoleARN = "arn:aws:iam::111122223333:role/eks-node" + +// newEnsurerWithRole returns a mock holding roleName with the canonical ARN IAM +// would have stored for it, which node role resolution verifies against. +func newEnsurerWithRole(roleName, roleARN string) *iammock.SystemInstanceRoleEnsurer { f := newFakeEnsurer() + f.Roles[roleName] = &iam.Role{RoleName: aws.String(roleName), Arn: aws.String(roleARN)} + return f +} + +func TestEnsureNodeInstanceProfile_CreatesAndAttaches(t *testing.T) { + f := newEnsurerWithRole("eks-quickstart-node-role", testNodeRoleARN) s := &EKSServiceImpl{deps: EKSServiceDeps{IAM: f}} arn, err := s.ensureNodeInstanceProfile("000000000001", testNodeRoleARN) @@ -33,7 +46,7 @@ func TestEnsureNodeInstanceProfile_CreatesAndAttaches(t *testing.T) { } func TestEnsureNodeInstanceProfile_ExistingWithRoleIsNoop(t *testing.T) { - f := newFakeEnsurer() + f := newEnsurerWithRole("eks-quickstart-node-role", testNodeRoleARN) f.Profiles["eks-quickstart-node-role"] = &iam.InstanceProfile{ InstanceProfileName: aws.String("eks-quickstart-node-role"), Arn: aws.String("arn:aws:iam::000000000001:instance-profile/eks-quickstart-node-role"), @@ -49,7 +62,7 @@ func TestEnsureNodeInstanceProfile_ExistingWithRoleIsNoop(t *testing.T) { } func TestEnsureNodeInstanceProfile_ExistingWithoutRoleAttaches(t *testing.T) { - f := newFakeEnsurer() + f := newEnsurerWithRole("eks-quickstart-node-role", testNodeRoleARN) f.Profiles["eks-quickstart-node-role"] = &iam.InstanceProfile{ InstanceProfileName: aws.String("eks-quickstart-node-role"), Arn: aws.String("arn:aws:iam::000000000001:instance-profile/eks-quickstart-node-role"), @@ -63,7 +76,7 @@ func TestEnsureNodeInstanceProfile_ExistingWithoutRoleAttaches(t *testing.T) { } func TestEnsureNodeInstanceProfile_AddRoleLimitExceededIsSuccess(t *testing.T) { - f := newFakeEnsurer() + f := newEnsurerWithRole("eks-quickstart-node-role", testNodeRoleARN) f.AddRoleToInstanceProfileErr = errors.New(awserrors.ErrorIAMLimitExceeded) s := &EKSServiceImpl{deps: EKSServiceDeps{IAM: f}} @@ -75,7 +88,7 @@ func TestEnsureNodeInstanceProfile_AddRoleLimitExceededIsSuccess(t *testing.T) { // A path-bearing role ARN names its profile after the final segment only; the // path is not a legal instance-profile name character. func TestEnsureNodeInstanceProfile_PathBearingRoleUsesFinalSegment(t *testing.T) { - f := newFakeEnsurer() + f := newEnsurerWithRole("Worker", "arn:aws:iam::000000000001:role/team/Worker") s := &EKSServiceImpl{deps: EKSServiceDeps{IAM: f}} arn, err := s.ensureNodeInstanceProfile("000000000001", "arn:aws:iam::000000000001:role/team/Worker") @@ -94,11 +107,10 @@ func TestEnsureNodeInstanceProfile_RejectsInvalidARNs(t *testing.T) { "non-role resource": "arn:aws:iam::000000000001:user/bob", "empty role name": "arn:aws:iam::000000000001:role/", "empty account": "arn:aws:iam:::role/Worker", - "cross-account": "arn:aws:iam::000000000002:role/Worker", } for name, arn := range cases { t.Run(name, func(t *testing.T) { - f := newFakeEnsurer() + f := newEnsurerWithRole("Worker", "arn:aws:iam::000000000001:role/Worker") s := &EKSServiceImpl{deps: EKSServiceDeps{IAM: f}} _, err := s.ensureNodeInstanceProfile("000000000001", arn) require.Error(t, err) @@ -107,3 +119,53 @@ func TestEnsureNodeInstanceProfile_RejectsInvalidARNs(t *testing.T) { }) } } + +// A role another account owns is rejected by the account guard alone: the mock +// holds it under the ARN the caller supplied, so the canonical comparison would +// pass and only the guard can refuse it. +func TestEnsureNodeInstanceProfile_RejectsCrossAccountRole(t *testing.T) { + const crossAccountARN = "arn:aws:iam::000000000002:role/Worker" + f := newEnsurerWithRole("Worker", crossAccountARN) + s := &EKSServiceImpl{deps: EKSServiceDeps{IAM: f}} + + _, err := s.ensureNodeInstanceProfile("000000000001", crossAccountARN) + require.Error(t, err) + assert.NotErrorIs(t, err, auth.ErrRoleARNMismatch, "the guard must reject, not the ARN comparison") + assert.ErrorContains(t, err, "is not in account 000000000001") + assert.Zero(t, f.CreateInstanceProfileCalls) + assert.Zero(t, f.AddRoleToInstanceProfileCalls) +} + +// A node role ARN carrying a path the stored role does not have must not bind a +// profile to the role its final segment names. +func TestEnsureNodeInstanceProfile_RejectsFabricatedPath(t *testing.T) { + f := newEnsurerWithRole("eks-quickstart-node-role", testNodeRoleARN) + s := &EKSServiceImpl{deps: EKSServiceDeps{IAM: f}} + + _, err := s.ensureNodeInstanceProfile("000000000001", + "arn:aws:iam::000000000001:role/decoy/eks-quickstart-node-role") + require.ErrorIs(t, err, auth.ErrRoleARNMismatch) + assert.Zero(t, f.CreateInstanceProfileCalls) + assert.Zero(t, f.AddRoleToInstanceProfileCalls) +} + +// A pathed role is likewise not reachable by its pathless ARN. +func TestEnsureNodeInstanceProfile_RejectsStrippedPath(t *testing.T) { + f := newEnsurerWithRole("Worker", "arn:aws:iam::000000000001:role/team/Worker") + s := &EKSServiceImpl{deps: EKSServiceDeps{IAM: f}} + + _, err := s.ensureNodeInstanceProfile("000000000001", "arn:aws:iam::000000000001:role/Worker") + require.ErrorIs(t, err, auth.ErrRoleARNMismatch) + assert.Zero(t, f.CreateInstanceProfileCalls) +} + +// A node role that does not exist in IAM cannot be resolved, so no profile is +// created for it. +func TestEnsureNodeInstanceProfile_RejectsUnknownRole(t *testing.T) { + f := newFakeEnsurer() + s := &EKSServiceImpl{deps: EKSServiceDeps{IAM: f}} + + _, err := s.ensureNodeInstanceProfile("000000000001", testNodeRoleARN) + require.Error(t, err) + assert.Zero(t, f.CreateInstanceProfileCalls) +} diff --git a/spinifex/handlers/eks/nodegroup_test.go b/spinifex/handlers/eks/nodegroup_test.go index f095c67c4..4f28ec4e6 100644 --- a/spinifex/handlers/eks/nodegroup_test.go +++ b/spinifex/handlers/eks/nodegroup_test.go @@ -181,7 +181,7 @@ func createNGInput(cluster, ng string, desired int64) *eks.CreateNodegroupInput return &eks.CreateNodegroupInput{ ClusterName: aws.String(cluster), NodegroupName: aws.String(ng), - NodeRole: aws.String("arn:aws:iam::111122223333:role/eks-node"), + NodeRole: aws.String(fixtureNodeRoleARN), Subnets: aws.StringSlice([]string{"subnet-aaa"}), ScalingConfig: &eks.NodegroupScalingConfig{ MinSize: aws.Int64(1), diff --git a/spinifex/handlers/sts/assume_role_with_web_identity.go b/spinifex/handlers/sts/assume_role_with_web_identity.go index 5512454ed..0d2bf9012 100644 --- a/spinifex/handlers/sts/assume_role_with_web_identity.go +++ b/spinifex/handlers/sts/assume_role_with_web_identity.go @@ -12,7 +12,6 @@ import ( "strings" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/sts" "github.com/golang-jwt/jwt/v5" "github.com/nats-io/nats.go/jetstream" @@ -49,7 +48,10 @@ func (s *STSServiceImpl) AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebI return nil, errors.New(awserrors.ErrorPackedPolicyTooLarge) } - roleAccountID, roleName, err := auth.ParseRoleARN(roleARN) + // Only the account is taken here: webIdentityKeyFunc needs it before the + // token is verified, and resolving the role would put an IAM read ahead of + // verification. The full ARN is verified below. + roleAccountID, _, err := auth.ParseRoleARN(roleARN) if err != nil { return nil, errors.New(awserrors.ErrorValidationError) } @@ -77,15 +79,15 @@ func (s *STSServiceImpl) AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebI return nil, errors.New(awserrors.ErrorInvalidIdentityToken) } - roleOut, err := s.iamSvc.GetRole(roleAccountID, &iam.GetRoleInput{RoleName: aws.String(roleName)}) + _, role, err := ResolveRoleByARN(s.iamSvc, roleARN) if err != nil { // All misses → AccessDenied to prevent cross-account role enumeration. - if err.Error() == awserrors.ErrorIAMNoSuchEntity { + // A supplied ARN that is not the stored one is one of those misses. + if errors.Is(err, ErrRoleUnresolved) { return nil, errors.New(awserrors.ErrorAccessDenied) } return nil, err } - role := roleOut.Role duration := defaultDurationSeconds if input.DurationSeconds != nil { diff --git a/spinifex/handlers/sts/assume_role_with_web_identity_test.go b/spinifex/handlers/sts/assume_role_with_web_identity_test.go index d60f47f09..cbd1492b2 100644 --- a/spinifex/handlers/sts/assume_role_with_web_identity_test.go +++ b/spinifex/handlers/sts/assume_role_with_web_identity_test.go @@ -616,3 +616,30 @@ func TestJWKToECDSAPublicKey_RejectsUnsupportedShapes(t *testing.T) { }) } } + +// A RoleArn carrying a path the stored role does not have must not reach the +// role its final segment names, even when the token and trust policy are valid. +func TestAssumeRoleWithWebIdentity_RejectsFabricatedPath(t *testing.T) { + svc, _ := newTestSetup(t) + f := newWebIdentityFixture(t, svc, testCallerAccountID) + role := createRoleForFixture(t, svc, testCallerAccountID, "irsa-app", + webIdentityTrustPolicy(f.federatedARN, f.issuer, "system:serviceaccount:default:my-sa")) + + token := f.signToken(f.defaultClaims()) + pathed := strings.Replace(aws.StringValue(role.Arn), ":role/", ":role/decoy/", 1) + _, err := svc.AssumeRoleWithWebIdentity(&sts.AssumeRoleWithWebIdentityInput{ + RoleArn: aws.String(pathed), + RoleSessionName: aws.String("pod-1"), + WebIdentityToken: aws.String(token), + }) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorAccessDenied, err.Error()) + + // The stored ARN still works, so the denial is the path and nothing else. + _, err = svc.AssumeRoleWithWebIdentity(&sts.AssumeRoleWithWebIdentityInput{ + RoleArn: role.Arn, + RoleSessionName: aws.String("pod-1"), + WebIdentityToken: aws.String(token), + }) + require.NoError(t, err) +} diff --git a/spinifex/handlers/sts/role_arn.go b/spinifex/handlers/sts/role_arn.go index 7d54d3f9d..f3589783d 100644 --- a/spinifex/handlers/sts/role_arn.go +++ b/spinifex/handlers/sts/role_arn.go @@ -25,19 +25,28 @@ type RoleGetter interface { // discards any path in the supplied ARN, so comparing the stored ARN back is // what stops an invented path reaching a role the ARN does not name. func ResolveRoleByARN(svc RoleGetter, roleARN string) (string, *iam.Role, error) { - accountID, roleName, err := auth.ParseRoleARN(roleARN) - if err != nil { + var role *iam.Role + accountID, _, err := auth.ResolveRoleARN(roleARN, func(accountID, roleName string) (string, error) { + out, err := svc.GetRole(accountID, &iam.GetRoleInput{RoleName: aws.String(roleName)}) + if err != nil { + return "", err + } + if out == nil || out.Role == nil { + return "", nil + } + role = out.Role + return aws.StringValue(out.Role.Arn), nil + }) + switch { + case errors.Is(err, auth.ErrInvalidRoleARN): return "", nil, errors.New(awserrors.ErrorValidationError) - } - out, err := svc.GetRole(accountID, &iam.GetRoleInput{RoleName: aws.String(roleName)}) - if err != nil { + case errors.Is(err, auth.ErrRoleARNMismatch): + return "", nil, ErrRoleUnresolved + case err != nil: if err.Error() == awserrors.ErrorIAMNoSuchEntity { return "", nil, ErrRoleUnresolved } return "", nil, err } - if out == nil || out.Role == nil || aws.StringValue(out.Role.Arn) != roleARN { - return "", nil, ErrRoleUnresolved - } - return accountID, out.Role, nil + return accountID, role, nil }