From 20857eaa3f410fa82a01015d3984cc380301a8bd Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 7 Sep 2026 10:57:04 +0200 Subject: [PATCH 1/2] fix(plugins): guard integration plugins against SSRF Adds an SSRF-safe HTTP client to the plugin SDK and routes every plugin that makes outbound requests to an operator-supplied URL through it. The client resolves the destination, refuses addresses that are not publicly routable, and dials the very address it validated, so DNS cannot answer differently between the check and the connection. Every redirect hop opens its own connection and is validated again. The Slack and Discord webhooks only ever target their public services, so they always refuse non-public destinations. The generic webhook and Dependency-Track plugins accept an arbitrary destination that a deployment may well run inside its own network, so their behaviour is left to the new plugins_network_policy.block_private_targets control plane setting, off by default. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 481e7cbc-976a-4f8a-ad64-9f3ee9a643d6, e6906bba-e9a1-468f-8d04-e3fe7ae231df --- app/controlplane/cmd/main.go | 6 +- .../conf/controlplane/config/v1/conf.pb.go | 335 +++++++++++------- .../conf/controlplane/config/v1/conf.proto | 16 + .../core/dependency-track/v1/client/sbom.go | 38 +- .../dependency-track/v1/client/sbom_test.go | 17 +- .../core/dependency-track/v1/cmd/main.go | 11 +- .../core/dependency-track/v1/extension.go | 30 +- .../dependency-track/v1/extension_test.go | 66 +++- .../core/discord-webhook/v1/discord.go | 30 +- .../core/discord-webhook/v1/discord_test.go | 37 +- .../core/slack-webhook/v1/slack_webhook.go | 23 +- .../slack-webhook/v1/slack_webhook_test.go | 37 +- .../plugins/core/webhook/v1/webhook.go | 13 +- .../plugins/core/webhook/v1/webhook_test.go | 103 ++++++ app/controlplane/plugins/plugins.go | 10 +- .../plugins/sdk/readme-generator/main.go | 2 +- app/controlplane/plugins/sdk/v1/httpclient.go | 194 ++++++++++ .../plugins/sdk/v1/httpclient_test.go | 229 ++++++++++++ deployment/chainloop/Chart.yaml | 2 +- deployment/chainloop/README.md | 2 + .../templates/controlplane/configmap.yaml | 2 + deployment/chainloop/values.yaml | 5 + devel/integrations.md | 4 +- 23 files changed, 1009 insertions(+), 203 deletions(-) create mode 100644 app/controlplane/plugins/core/webhook/v1/webhook_test.go create mode 100644 app/controlplane/plugins/sdk/v1/httpclient.go create mode 100644 app/controlplane/plugins/sdk/v1/httpclient_test.go diff --git a/app/controlplane/cmd/main.go b/app/controlplane/cmd/main.go index 38bbc972c..56f549ef1 100644 --- a/app/controlplane/cmd/main.go +++ b/app/controlplane/cmd/main.go @@ -133,7 +133,11 @@ func main() { } // Load plugins - availablePlugins, err := plugins.Load(bc.GetPluginsDir(), logger) + netPolicy := sdk.NetworkPolicy{ + BlockPrivateTargets: bc.GetPluginsNetworkPolicy().GetBlockPrivateTargets(), + } + + availablePlugins, err := plugins.Load(bc.GetPluginsDir(), netPolicy, logger) if err != nil { panic(err) } diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go index aeccaa51e..979a206b4 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go @@ -81,9 +81,11 @@ type Bootstrap struct { // Optional external operation authorization provider OperationAuthorizationProvider *OperationAuthorizationProvider `protobuf:"bytes,20,opt,name=operation_authorization_provider,json=operationAuthorizationProvider,proto3" json:"operation_authorization_provider,omitempty"` // Attestation storage and processing options - Attestations *Attestations `protobuf:"bytes,21,opt,name=attestations,proto3" json:"attestations,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Attestations *Attestations `protobuf:"bytes,21,opt,name=attestations,proto3" json:"attestations,omitempty"` + // Outbound network policy applied to the integration plugins + PluginsNetworkPolicy *PluginsNetworkPolicy `protobuf:"bytes,22,opt,name=plugins_network_policy,json=pluginsNetworkPolicy,proto3" json:"plugins_network_policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Bootstrap) Reset() { @@ -257,6 +259,66 @@ func (x *Bootstrap) GetAttestations() *Attestations { return nil } +func (x *Bootstrap) GetPluginsNetworkPolicy() *PluginsNetworkPolicy { + if x != nil { + return x.PluginsNetworkPolicy + } + return nil +} + +type PluginsNetworkPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Refuse to connect to destinations that are not publicly routable + // (loopback, private ranges, cloud metadata endpoints) from the plugins + // that accept an arbitrary target URL, currently the generic webhook and + // Dependency-Track plugins. + // + // It defaults to false so that deployments running those services inside + // their own network keep working. Plugins whose destination is a known + // public service, such as the Slack and Discord webhooks, always refuse + // non-public destinations regardless of this setting. + BlockPrivateTargets bool `protobuf:"varint,1,opt,name=block_private_targets,json=blockPrivateTargets,proto3" json:"block_private_targets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PluginsNetworkPolicy) Reset() { + *x = PluginsNetworkPolicy{} + mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PluginsNetworkPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PluginsNetworkPolicy) ProtoMessage() {} + +func (x *PluginsNetworkPolicy) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PluginsNetworkPolicy.ProtoReflect.Descriptor instead. +func (*PluginsNetworkPolicy) Descriptor() ([]byte, []int) { + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1} +} + +func (x *PluginsNetworkPolicy) GetBlockPrivateTargets() bool { + if x != nil { + return x.BlockPrivateTargets + } + return false +} + type Attestations struct { state protoimpl.MessageState `protogen:"open.v1"` // When true, skip writing the attestation bundle to the per-run row in @@ -272,7 +334,7 @@ type Attestations struct { func (x *Attestations) Reset() { *x = Attestations{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -284,7 +346,7 @@ func (x *Attestations) String() string { func (*Attestations) ProtoMessage() {} func (x *Attestations) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[1] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -297,7 +359,7 @@ func (x *Attestations) ProtoReflect() protoreflect.Message { // Deprecated: Use Attestations.ProtoReflect.Descriptor instead. func (*Attestations) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{1} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{2} } func (x *Attestations) GetSkipDbStorage() bool { @@ -321,7 +383,7 @@ type OperationAuthorizationProvider struct { func (x *OperationAuthorizationProvider) Reset() { *x = OperationAuthorizationProvider{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -333,7 +395,7 @@ func (x *OperationAuthorizationProvider) String() string { func (*OperationAuthorizationProvider) ProtoMessage() {} func (x *OperationAuthorizationProvider) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[2] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -346,7 +408,7 @@ func (x *OperationAuthorizationProvider) ProtoReflect() protoreflect.Message { // Deprecated: Use OperationAuthorizationProvider.ProtoReflect.Descriptor instead. func (*OperationAuthorizationProvider) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{2} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{3} } func (x *OperationAuthorizationProvider) GetUrl() string { @@ -382,7 +444,7 @@ type FederatedAuthentication struct { func (x *FederatedAuthentication) Reset() { *x = FederatedAuthentication{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -394,7 +456,7 @@ func (x *FederatedAuthentication) String() string { func (*FederatedAuthentication) ProtoMessage() {} func (x *FederatedAuthentication) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[3] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -407,7 +469,7 @@ func (x *FederatedAuthentication) ProtoReflect() protoreflect.Message { // Deprecated: Use FederatedAuthentication.ProtoReflect.Descriptor instead. func (*FederatedAuthentication) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{3} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{4} } func (x *FederatedAuthentication) GetUrl() string { @@ -441,7 +503,7 @@ type PolicyProvider struct { func (x *PolicyProvider) Reset() { *x = PolicyProvider{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -453,7 +515,7 @@ func (x *PolicyProvider) String() string { func (*PolicyProvider) ProtoMessage() {} func (x *PolicyProvider) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[4] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -466,7 +528,7 @@ func (x *PolicyProvider) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyProvider.ProtoReflect.Descriptor instead. func (*PolicyProvider) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{4} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5} } func (x *PolicyProvider) GetName() string { @@ -510,7 +572,7 @@ type Server struct { func (x *Server) Reset() { *x = Server{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -522,7 +584,7 @@ func (x *Server) String() string { func (*Server) ProtoMessage() {} func (x *Server) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[5] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -535,7 +597,7 @@ func (x *Server) ProtoReflect() protoreflect.Message { // Deprecated: Use Server.ProtoReflect.Descriptor instead. func (*Server) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6} } func (x *Server) GetHttp() *Server_HTTP { @@ -568,7 +630,7 @@ type Data struct { func (x *Data) Reset() { *x = Data{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -580,7 +642,7 @@ func (x *Data) String() string { func (*Data) ProtoMessage() {} func (x *Data) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[6] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -593,7 +655,7 @@ func (x *Data) ProtoReflect() protoreflect.Message { // Deprecated: Use Data.ProtoReflect.Descriptor instead. func (*Data) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7} } func (x *Data) GetDatabase() *Data_Database { @@ -618,7 +680,7 @@ type Auth struct { func (x *Auth) Reset() { *x = Auth{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -630,7 +692,7 @@ func (x *Auth) String() string { func (*Auth) ProtoMessage() {} func (x *Auth) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[7] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -643,7 +705,7 @@ func (x *Auth) ProtoReflect() protoreflect.Message { // Deprecated: Use Auth.ProtoReflect.Descriptor instead. func (*Auth) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8} } func (x *Auth) GetGeneratedJwsHmacSecret() string { @@ -695,7 +757,7 @@ type TSA struct { func (x *TSA) Reset() { *x = TSA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -707,7 +769,7 @@ func (x *TSA) String() string { func (*TSA) ProtoMessage() {} func (x *TSA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[8] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -720,7 +782,7 @@ func (x *TSA) ProtoReflect() protoreflect.Message { // Deprecated: Use TSA.ProtoReflect.Descriptor instead. func (*TSA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9} } func (x *TSA) GetUrl() string { @@ -761,7 +823,7 @@ type CA struct { func (x *CA) Reset() { *x = CA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -773,7 +835,7 @@ func (x *CA) String() string { func (*CA) ProtoMessage() {} func (x *CA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[9] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -786,7 +848,7 @@ func (x *CA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA.ProtoReflect.Descriptor instead. func (*CA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10} } func (x *CA) GetCa() isCA_Ca { @@ -848,7 +910,7 @@ type PrometheusIntegrationSpec struct { func (x *PrometheusIntegrationSpec) Reset() { *x = PrometheusIntegrationSpec{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -860,7 +922,7 @@ func (x *PrometheusIntegrationSpec) String() string { func (*PrometheusIntegrationSpec) ProtoMessage() {} func (x *PrometheusIntegrationSpec) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[10] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -873,7 +935,7 @@ func (x *PrometheusIntegrationSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use PrometheusIntegrationSpec.ProtoReflect.Descriptor instead. func (*PrometheusIntegrationSpec) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{11} } func (x *PrometheusIntegrationSpec) GetOrgName() string { @@ -893,7 +955,7 @@ type Bootstrap_Observability struct { func (x *Bootstrap_Observability) Reset() { *x = Bootstrap_Observability{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -905,7 +967,7 @@ func (x *Bootstrap_Observability) String() string { func (*Bootstrap_Observability) ProtoMessage() {} func (x *Bootstrap_Observability) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[11] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -955,7 +1017,7 @@ type Bootstrap_CASServer struct { func (x *Bootstrap_CASServer) Reset() { *x = Bootstrap_CASServer{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -967,7 +1029,7 @@ func (x *Bootstrap_CASServer) String() string { func (*Bootstrap_CASServer) ProtoMessage() {} func (x *Bootstrap_CASServer) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[12] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1028,7 +1090,7 @@ type Bootstrap_NatsServer struct { func (x *Bootstrap_NatsServer) Reset() { *x = Bootstrap_NatsServer{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1040,7 +1102,7 @@ func (x *Bootstrap_NatsServer) String() string { func (*Bootstrap_NatsServer) ProtoMessage() {} func (x *Bootstrap_NatsServer) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[13] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1107,7 +1169,7 @@ type Bootstrap_Observability_Sentry struct { func (x *Bootstrap_Observability_Sentry) Reset() { *x = Bootstrap_Observability_Sentry{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1119,7 +1181,7 @@ func (x *Bootstrap_Observability_Sentry) String() string { func (*Bootstrap_Observability_Sentry) ProtoMessage() {} func (x *Bootstrap_Observability_Sentry) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[14] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1166,7 +1228,7 @@ type Bootstrap_Observability_Tracing struct { func (x *Bootstrap_Observability_Tracing) Reset() { *x = Bootstrap_Observability_Tracing{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1178,7 +1240,7 @@ func (x *Bootstrap_Observability_Tracing) String() string { func (*Bootstrap_Observability_Tracing) ProtoMessage() {} func (x *Bootstrap_Observability_Tracing) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[15] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1236,7 +1298,7 @@ type Server_HTTP struct { func (x *Server_HTTP) Reset() { *x = Server_HTTP{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1248,7 +1310,7 @@ func (x *Server_HTTP) String() string { func (*Server_HTTP) ProtoMessage() {} func (x *Server_HTTP) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[16] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1261,7 +1323,7 @@ func (x *Server_HTTP) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_HTTP.ProtoReflect.Descriptor instead. func (*Server_HTTP) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 0} } func (x *Server_HTTP) GetNetwork() string { @@ -1303,7 +1365,7 @@ type Server_TLS struct { func (x *Server_TLS) Reset() { *x = Server_TLS{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1315,7 +1377,7 @@ func (x *Server_TLS) String() string { func (*Server_TLS) ProtoMessage() {} func (x *Server_TLS) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[17] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1328,7 +1390,7 @@ func (x *Server_TLS) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_TLS.ProtoReflect.Descriptor instead. func (*Server_TLS) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5, 1} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 1} } func (x *Server_TLS) GetCertificate() string { @@ -1360,7 +1422,7 @@ type Server_GRPC struct { func (x *Server_GRPC) Reset() { *x = Server_GRPC{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1372,7 +1434,7 @@ func (x *Server_GRPC) String() string { func (*Server_GRPC) ProtoMessage() {} func (x *Server_GRPC) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[18] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1385,7 +1447,7 @@ func (x *Server_GRPC) ProtoReflect() protoreflect.Message { // Deprecated: Use Server_GRPC.ProtoReflect.Descriptor instead. func (*Server_GRPC) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{5, 2} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 2} } func (x *Server_GRPC) GetNetwork() string { @@ -1439,7 +1501,7 @@ type Data_Database struct { func (x *Data_Database) Reset() { *x = Data_Database{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1451,7 +1513,7 @@ func (x *Data_Database) String() string { func (*Data_Database) ProtoMessage() {} func (x *Data_Database) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[19] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1464,7 +1526,7 @@ func (x *Data_Database) ProtoReflect() protoreflect.Message { // Deprecated: Use Data_Database.ProtoReflect.Descriptor instead. func (*Data_Database) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{6, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 0} } func (x *Data_Database) GetDriver() string { @@ -1516,7 +1578,7 @@ type Auth_OIDC struct { func (x *Auth_OIDC) Reset() { *x = Auth_OIDC{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1528,7 +1590,7 @@ func (x *Auth_OIDC) String() string { func (*Auth_OIDC) ProtoMessage() {} func (x *Auth_OIDC) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[20] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1541,7 +1603,7 @@ func (x *Auth_OIDC) ProtoReflect() protoreflect.Message { // Deprecated: Use Auth_OIDC.ProtoReflect.Descriptor instead. func (*Auth_OIDC) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{7, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{8, 0} } func (x *Auth_OIDC) GetDomain() string { @@ -1583,7 +1645,7 @@ type CA_FileCA struct { func (x *CA_FileCA) Reset() { *x = CA_FileCA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1595,7 +1657,7 @@ func (x *CA_FileCA) String() string { func (*CA_FileCA) ProtoMessage() {} func (x *CA_FileCA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[21] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1608,7 +1670,7 @@ func (x *CA_FileCA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA_FileCA.ProtoReflect.Descriptor instead. func (*CA_FileCA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9, 0} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10, 0} } func (x *CA_FileCA) GetCertPath() string { @@ -1649,7 +1711,7 @@ type CA_EJBCA struct { func (x *CA_EJBCA) Reset() { *x = CA_EJBCA{} - mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1661,7 +1723,7 @@ func (x *CA_EJBCA) String() string { func (*CA_EJBCA) ProtoMessage() {} func (x *CA_EJBCA) ProtoReflect() protoreflect.Message { - mi := &file_controlplane_config_v1_conf_proto_msgTypes[22] + mi := &file_controlplane_config_v1_conf_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1674,7 +1736,7 @@ func (x *CA_EJBCA) ProtoReflect() protoreflect.Message { // Deprecated: Use CA_EJBCA.ProtoReflect.Descriptor instead. func (*CA_EJBCA) Descriptor() ([]byte, []int) { - return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{9, 1} + return file_controlplane_config_v1_conf_proto_rawDescGZIP(), []int{10, 1} } func (x *CA_EJBCA) GetServerUrl() string { @@ -1730,7 +1792,7 @@ var File_controlplane_config_v1_conf_proto protoreflect.FileDescriptor const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\n" + - "!controlplane/config/v1/conf.proto\x12\x16controlplane.config.v1\x1a\x1bbuf/validate/validate.proto\x1a#controlplane/config/v1/config.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\xb1\x11\n" + + "!controlplane/config/v1/conf.proto\x12\x16controlplane.config.v1\x1a\x1bbuf/validate/validate.proto\x1a#controlplane/config/v1/config.proto\x1a\x1bcredentials/v1/config.proto\x1a\x1egoogle/protobuf/duration.proto\"\x95\x12\n" + "\tBootstrap\x126\n" + "\x06server\x18\x01 \x01(\v2\x1e.controlplane.config.v1.ServerR\x06server\x120\n" + "\x04data\x18\x02 \x01(\v2\x1c.controlplane.config.v1.DataR\x04data\x120\n" + @@ -1757,7 +1819,8 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x15restrict_org_creation\x18\x12 \x01(\bR\x13restrictOrgCreation\x12(\n" + "\x10ui_dashboard_url\x18\x13 \x01(\tR\x0euiDashboardUrl\x12\x80\x01\n" + " operation_authorization_provider\x18\x14 \x01(\v26.controlplane.config.v1.OperationAuthorizationProviderR\x1eoperationAuthorizationProvider\x12H\n" + - "\fattestations\x18\x15 \x01(\v2$.controlplane.config.v1.AttestationsR\fattestations\x1a\x8d\x03\n" + + "\fattestations\x18\x15 \x01(\v2$.controlplane.config.v1.AttestationsR\fattestations\x12b\n" + + "\x16plugins_network_policy\x18\x16 \x01(\v2,.controlplane.config.v1.PluginsNetworkPolicyR\x14pluginsNetworkPolicy\x1a\x8d\x03\n" + "\rObservability\x12N\n" + "\x06sentry\x18\x01 \x01(\v26.controlplane.config.v1.Bootstrap.Observability.SentryR\x06sentry\x12Q\n" + "\atracing\x18\x02 \x01(\v27.controlplane.config.v1.Bootstrap.Observability.TracingR\atracing\x1a<\n" + @@ -1780,7 +1843,9 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" + "\x03uri\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x03uri\x12\x1f\n" + "\x05token\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x05token\x12\x1a\n" + "\breplicas\x18\x03 \x01(\x05R\breplicasB\x10\n" + - "\x0eauthenticationJ\x04\b\b\x10\tR\x15referrer_shared_index\"6\n" + + "\x0eauthenticationJ\x04\b\b\x10\tR\x15referrer_shared_index\"J\n" + + "\x14PluginsNetworkPolicy\x122\n" + + "\x15block_private_targets\x18\x01 \x01(\bR\x13blockPrivateTargets\"6\n" + "\fAttestations\x12&\n" + "\x0fskip_db_storage\x18\x01 \x01(\bR\rskipDbStorage\"v\n" + "\x1eOperationAuthorizationProvider\x12\x1a\n" + @@ -1876,73 +1941,75 @@ func file_controlplane_config_v1_conf_proto_rawDescGZIP() []byte { return file_controlplane_config_v1_conf_proto_rawDescData } -var file_controlplane_config_v1_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 23) +var file_controlplane_config_v1_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_controlplane_config_v1_conf_proto_goTypes = []any{ (*Bootstrap)(nil), // 0: controlplane.config.v1.Bootstrap - (*Attestations)(nil), // 1: controlplane.config.v1.Attestations - (*OperationAuthorizationProvider)(nil), // 2: controlplane.config.v1.OperationAuthorizationProvider - (*FederatedAuthentication)(nil), // 3: controlplane.config.v1.FederatedAuthentication - (*PolicyProvider)(nil), // 4: controlplane.config.v1.PolicyProvider - (*Server)(nil), // 5: controlplane.config.v1.Server - (*Data)(nil), // 6: controlplane.config.v1.Data - (*Auth)(nil), // 7: controlplane.config.v1.Auth - (*TSA)(nil), // 8: controlplane.config.v1.TSA - (*CA)(nil), // 9: controlplane.config.v1.CA - (*PrometheusIntegrationSpec)(nil), // 10: controlplane.config.v1.PrometheusIntegrationSpec - (*Bootstrap_Observability)(nil), // 11: controlplane.config.v1.Bootstrap.Observability - (*Bootstrap_CASServer)(nil), // 12: controlplane.config.v1.Bootstrap.CASServer - (*Bootstrap_NatsServer)(nil), // 13: controlplane.config.v1.Bootstrap.NatsServer - (*Bootstrap_Observability_Sentry)(nil), // 14: controlplane.config.v1.Bootstrap.Observability.Sentry - (*Bootstrap_Observability_Tracing)(nil), // 15: controlplane.config.v1.Bootstrap.Observability.Tracing - (*Server_HTTP)(nil), // 16: controlplane.config.v1.Server.HTTP - (*Server_TLS)(nil), // 17: controlplane.config.v1.Server.TLS - (*Server_GRPC)(nil), // 18: controlplane.config.v1.Server.GRPC - (*Data_Database)(nil), // 19: controlplane.config.v1.Data.Database - (*Auth_OIDC)(nil), // 20: controlplane.config.v1.Auth.OIDC - (*CA_FileCA)(nil), // 21: controlplane.config.v1.CA.FileCA - (*CA_EJBCA)(nil), // 22: controlplane.config.v1.CA.EJBCA - (*v1.Credentials)(nil), // 23: credentials.v1.Credentials - (*v11.OnboardingSpec)(nil), // 24: controlplane.config.v1.OnboardingSpec - (*v11.AllowList)(nil), // 25: controlplane.config.v1.AllowList - (*durationpb.Duration)(nil), // 26: google.protobuf.Duration + (*PluginsNetworkPolicy)(nil), // 1: controlplane.config.v1.PluginsNetworkPolicy + (*Attestations)(nil), // 2: controlplane.config.v1.Attestations + (*OperationAuthorizationProvider)(nil), // 3: controlplane.config.v1.OperationAuthorizationProvider + (*FederatedAuthentication)(nil), // 4: controlplane.config.v1.FederatedAuthentication + (*PolicyProvider)(nil), // 5: controlplane.config.v1.PolicyProvider + (*Server)(nil), // 6: controlplane.config.v1.Server + (*Data)(nil), // 7: controlplane.config.v1.Data + (*Auth)(nil), // 8: controlplane.config.v1.Auth + (*TSA)(nil), // 9: controlplane.config.v1.TSA + (*CA)(nil), // 10: controlplane.config.v1.CA + (*PrometheusIntegrationSpec)(nil), // 11: controlplane.config.v1.PrometheusIntegrationSpec + (*Bootstrap_Observability)(nil), // 12: controlplane.config.v1.Bootstrap.Observability + (*Bootstrap_CASServer)(nil), // 13: controlplane.config.v1.Bootstrap.CASServer + (*Bootstrap_NatsServer)(nil), // 14: controlplane.config.v1.Bootstrap.NatsServer + (*Bootstrap_Observability_Sentry)(nil), // 15: controlplane.config.v1.Bootstrap.Observability.Sentry + (*Bootstrap_Observability_Tracing)(nil), // 16: controlplane.config.v1.Bootstrap.Observability.Tracing + (*Server_HTTP)(nil), // 17: controlplane.config.v1.Server.HTTP + (*Server_TLS)(nil), // 18: controlplane.config.v1.Server.TLS + (*Server_GRPC)(nil), // 19: controlplane.config.v1.Server.GRPC + (*Data_Database)(nil), // 20: controlplane.config.v1.Data.Database + (*Auth_OIDC)(nil), // 21: controlplane.config.v1.Auth.OIDC + (*CA_FileCA)(nil), // 22: controlplane.config.v1.CA.FileCA + (*CA_EJBCA)(nil), // 23: controlplane.config.v1.CA.EJBCA + (*v1.Credentials)(nil), // 24: credentials.v1.Credentials + (*v11.OnboardingSpec)(nil), // 25: controlplane.config.v1.OnboardingSpec + (*v11.AllowList)(nil), // 26: controlplane.config.v1.AllowList + (*durationpb.Duration)(nil), // 27: google.protobuf.Duration } var file_controlplane_config_v1_conf_proto_depIdxs = []int32{ - 5, // 0: controlplane.config.v1.Bootstrap.server:type_name -> controlplane.config.v1.Server - 6, // 1: controlplane.config.v1.Bootstrap.data:type_name -> controlplane.config.v1.Data - 7, // 2: controlplane.config.v1.Bootstrap.auth:type_name -> controlplane.config.v1.Auth - 11, // 3: controlplane.config.v1.Bootstrap.observability:type_name -> controlplane.config.v1.Bootstrap.Observability - 23, // 4: controlplane.config.v1.Bootstrap.credentials_service:type_name -> credentials.v1.Credentials - 12, // 5: controlplane.config.v1.Bootstrap.cas_server:type_name -> controlplane.config.v1.Bootstrap.CASServer - 9, // 6: controlplane.config.v1.Bootstrap.certificate_authority:type_name -> controlplane.config.v1.CA - 9, // 7: controlplane.config.v1.Bootstrap.certificate_authorities:type_name -> controlplane.config.v1.CA - 8, // 8: controlplane.config.v1.Bootstrap.timestamp_authorities:type_name -> controlplane.config.v1.TSA - 24, // 9: controlplane.config.v1.Bootstrap.onboarding:type_name -> controlplane.config.v1.OnboardingSpec - 10, // 10: controlplane.config.v1.Bootstrap.prometheus_integration:type_name -> controlplane.config.v1.PrometheusIntegrationSpec - 4, // 11: controlplane.config.v1.Bootstrap.policy_providers:type_name -> controlplane.config.v1.PolicyProvider - 13, // 12: controlplane.config.v1.Bootstrap.nats_server:type_name -> controlplane.config.v1.Bootstrap.NatsServer - 3, // 13: controlplane.config.v1.Bootstrap.federated_authentication:type_name -> controlplane.config.v1.FederatedAuthentication - 2, // 14: controlplane.config.v1.Bootstrap.operation_authorization_provider:type_name -> controlplane.config.v1.OperationAuthorizationProvider - 1, // 15: controlplane.config.v1.Bootstrap.attestations:type_name -> controlplane.config.v1.Attestations - 16, // 16: controlplane.config.v1.Server.http:type_name -> controlplane.config.v1.Server.HTTP - 18, // 17: controlplane.config.v1.Server.grpc:type_name -> controlplane.config.v1.Server.GRPC - 16, // 18: controlplane.config.v1.Server.http_metrics:type_name -> controlplane.config.v1.Server.HTTP - 19, // 19: controlplane.config.v1.Data.database:type_name -> controlplane.config.v1.Data.Database - 25, // 20: controlplane.config.v1.Auth.allow_list:type_name -> controlplane.config.v1.AllowList - 20, // 21: controlplane.config.v1.Auth.oidc:type_name -> controlplane.config.v1.Auth.OIDC - 21, // 22: controlplane.config.v1.CA.file_ca:type_name -> controlplane.config.v1.CA.FileCA - 22, // 23: controlplane.config.v1.CA.ejbca_ca:type_name -> controlplane.config.v1.CA.EJBCA - 14, // 24: controlplane.config.v1.Bootstrap.Observability.sentry:type_name -> controlplane.config.v1.Bootstrap.Observability.Sentry - 15, // 25: controlplane.config.v1.Bootstrap.Observability.tracing:type_name -> controlplane.config.v1.Bootstrap.Observability.Tracing - 18, // 26: controlplane.config.v1.Bootstrap.CASServer.grpc:type_name -> controlplane.config.v1.Server.GRPC - 26, // 27: controlplane.config.v1.Server.HTTP.timeout:type_name -> google.protobuf.Duration - 26, // 28: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration - 17, // 29: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS - 26, // 30: controlplane.config.v1.Data.Database.max_conn_idle_time:type_name -> google.protobuf.Duration - 31, // [31:31] is the sub-list for method output_type - 31, // [31:31] is the sub-list for method input_type - 31, // [31:31] is the sub-list for extension type_name - 31, // [31:31] is the sub-list for extension extendee - 0, // [0:31] is the sub-list for field type_name + 6, // 0: controlplane.config.v1.Bootstrap.server:type_name -> controlplane.config.v1.Server + 7, // 1: controlplane.config.v1.Bootstrap.data:type_name -> controlplane.config.v1.Data + 8, // 2: controlplane.config.v1.Bootstrap.auth:type_name -> controlplane.config.v1.Auth + 12, // 3: controlplane.config.v1.Bootstrap.observability:type_name -> controlplane.config.v1.Bootstrap.Observability + 24, // 4: controlplane.config.v1.Bootstrap.credentials_service:type_name -> credentials.v1.Credentials + 13, // 5: controlplane.config.v1.Bootstrap.cas_server:type_name -> controlplane.config.v1.Bootstrap.CASServer + 10, // 6: controlplane.config.v1.Bootstrap.certificate_authority:type_name -> controlplane.config.v1.CA + 10, // 7: controlplane.config.v1.Bootstrap.certificate_authorities:type_name -> controlplane.config.v1.CA + 9, // 8: controlplane.config.v1.Bootstrap.timestamp_authorities:type_name -> controlplane.config.v1.TSA + 25, // 9: controlplane.config.v1.Bootstrap.onboarding:type_name -> controlplane.config.v1.OnboardingSpec + 11, // 10: controlplane.config.v1.Bootstrap.prometheus_integration:type_name -> controlplane.config.v1.PrometheusIntegrationSpec + 5, // 11: controlplane.config.v1.Bootstrap.policy_providers:type_name -> controlplane.config.v1.PolicyProvider + 14, // 12: controlplane.config.v1.Bootstrap.nats_server:type_name -> controlplane.config.v1.Bootstrap.NatsServer + 4, // 13: controlplane.config.v1.Bootstrap.federated_authentication:type_name -> controlplane.config.v1.FederatedAuthentication + 3, // 14: controlplane.config.v1.Bootstrap.operation_authorization_provider:type_name -> controlplane.config.v1.OperationAuthorizationProvider + 2, // 15: controlplane.config.v1.Bootstrap.attestations:type_name -> controlplane.config.v1.Attestations + 1, // 16: controlplane.config.v1.Bootstrap.plugins_network_policy:type_name -> controlplane.config.v1.PluginsNetworkPolicy + 17, // 17: controlplane.config.v1.Server.http:type_name -> controlplane.config.v1.Server.HTTP + 19, // 18: controlplane.config.v1.Server.grpc:type_name -> controlplane.config.v1.Server.GRPC + 17, // 19: controlplane.config.v1.Server.http_metrics:type_name -> controlplane.config.v1.Server.HTTP + 20, // 20: controlplane.config.v1.Data.database:type_name -> controlplane.config.v1.Data.Database + 26, // 21: controlplane.config.v1.Auth.allow_list:type_name -> controlplane.config.v1.AllowList + 21, // 22: controlplane.config.v1.Auth.oidc:type_name -> controlplane.config.v1.Auth.OIDC + 22, // 23: controlplane.config.v1.CA.file_ca:type_name -> controlplane.config.v1.CA.FileCA + 23, // 24: controlplane.config.v1.CA.ejbca_ca:type_name -> controlplane.config.v1.CA.EJBCA + 15, // 25: controlplane.config.v1.Bootstrap.Observability.sentry:type_name -> controlplane.config.v1.Bootstrap.Observability.Sentry + 16, // 26: controlplane.config.v1.Bootstrap.Observability.tracing:type_name -> controlplane.config.v1.Bootstrap.Observability.Tracing + 19, // 27: controlplane.config.v1.Bootstrap.CASServer.grpc:type_name -> controlplane.config.v1.Server.GRPC + 27, // 28: controlplane.config.v1.Server.HTTP.timeout:type_name -> google.protobuf.Duration + 27, // 29: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration + 18, // 30: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS + 27, // 31: controlplane.config.v1.Data.Database.max_conn_idle_time:type_name -> google.protobuf.Duration + 32, // [32:32] is the sub-list for method output_type + 32, // [32:32] is the sub-list for method input_type + 32, // [32:32] is the sub-list for extension type_name + 32, // [32:32] is the sub-list for extension extendee + 0, // [0:32] is the sub-list for field type_name } func init() { file_controlplane_config_v1_conf_proto_init() } @@ -1950,21 +2017,21 @@ func file_controlplane_config_v1_conf_proto_init() { if File_controlplane_config_v1_conf_proto != nil { return } - file_controlplane_config_v1_conf_proto_msgTypes[9].OneofWrappers = []any{ + file_controlplane_config_v1_conf_proto_msgTypes[10].OneofWrappers = []any{ (*CA_FileCa)(nil), (*CA_EjbcaCa)(nil), } - file_controlplane_config_v1_conf_proto_msgTypes[13].OneofWrappers = []any{ + file_controlplane_config_v1_conf_proto_msgTypes[14].OneofWrappers = []any{ (*Bootstrap_NatsServer_Token)(nil), } - file_controlplane_config_v1_conf_proto_msgTypes[15].OneofWrappers = []any{} + file_controlplane_config_v1_conf_proto_msgTypes[16].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_config_v1_conf_proto_rawDesc), len(file_controlplane_config_v1_conf_proto_rawDesc)), NumEnums: 0, - NumMessages: 23, + NumMessages: 24, NumExtensions: 0, NumServices: 0, }, diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto index 1a04c8881..8fdd75c0b 100644 --- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto +++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto @@ -126,6 +126,22 @@ message Bootstrap { // Attestation storage and processing options Attestations attestations = 21; + + // Outbound network policy applied to the integration plugins + PluginsNetworkPolicy plugins_network_policy = 22; +} + +message PluginsNetworkPolicy { + // Refuse to connect to destinations that are not publicly routable + // (loopback, private ranges, cloud metadata endpoints) from the plugins + // that accept an arbitrary target URL, currently the generic webhook and + // Dependency-Track plugins. + // + // It defaults to false so that deployments running those services inside + // their own network keep working. Plugins whose destination is a known + // public service, such as the Slack and Discord webhooks, always refuse + // non-public destinations regardless of this setting. + bool block_private_targets = 1; } message Attestations { diff --git a/app/controlplane/plugins/core/dependency-track/v1/client/sbom.go b/app/controlplane/plugins/core/dependency-track/v1/client/sbom.go index 0aaf7c84c..f58a726b2 100644 --- a/app/controlplane/plugins/core/dependency-track/v1/client/sbom.go +++ b/app/controlplane/plugins/core/dependency-track/v1/client/sbom.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,6 +32,10 @@ import ( type base struct { host *url.URL apiKey string + // httpClient carries the deployment's outbound network policy, so every + // request to the instance has to go through it rather than through + // http.DefaultClient. + httpClient *http.Client } type Integration struct { @@ -48,7 +52,7 @@ type SBOMUploader struct { parentID string } -func newBase(host, apiKey string) (*base, error) { +func newBase(httpClient *http.Client, host, apiKey string) (*base, error) { if apiKey == "" { return nil, errors.New("apiKey required") } @@ -58,12 +62,12 @@ func newBase(host, apiKey string) (*base, error) { return nil, err } - return &base{host: uri, apiKey: apiKey}, nil + return &base{host: uri, apiKey: apiKey, httpClient: httpClient}, nil } // The integration definition -func NewIntegration(host, apiKey string, checkAutoCreate bool) (*Integration, error) { - b, err := newBase(host, apiKey) +func NewIntegration(httpClient *http.Client, host, apiKey string, checkAutoCreate bool) (*Integration, error) { + b, err := newBase(httpClient, host, apiKey) if err != nil { return nil, err } @@ -71,8 +75,8 @@ func NewIntegration(host, apiKey string, checkAutoCreate bool) (*Integration, er return &Integration{base: b, checkAutoCreate: checkAutoCreate}, nil } -func NewSBOMUploader(host, apiKey string, sbom io.Reader, projectID, projectName string, parentID string) (*SBOMUploader, error) { - b, err := newBase(host, apiKey) +func NewSBOMUploader(httpClient *http.Client, host, apiKey string, sbom io.Reader, projectID, projectName string, parentID string) (*SBOMUploader, error) { + b, err := newBase(httpClient, host, apiKey) if err != nil { return nil, err } @@ -95,7 +99,7 @@ const viewPortfolioPermission = "VIEW_PORTFOLIO" const projectCreationPermission = "PROJECT_CREATION_UPLOAD" func (d *Integration) Validate(_ context.Context) error { - resp, err := teamPermissionsRequest(d.host, d.apiKey) + resp, err := teamPermissionsRequest(d.httpClient, d.host, d.apiKey) if err != nil { return err } @@ -115,7 +119,7 @@ func (d *Integration) Validate(_ context.Context) error { func (d *SBOMUploader) Validate(ctx context.Context) error { autocreate := d.projectName != "" && d.projectID == "" // Check auto-create permissions - integration, err := NewIntegration(d.host.String(), d.apiKey, autocreate) + integration, err := NewIntegration(d.httpClient, d.host.String(), d.apiKey, autocreate) if err != nil { return fmt.Errorf("intializing permissions checker: %w", err) } @@ -136,7 +140,7 @@ func (d *SBOMUploader) Validate(ctx context.Context) error { } // Check if the project or parent project exists - if projectFound, err := projectExists(d.host, d.apiKey, existingProjectID); err != nil { + if projectFound, err := projectExists(d.httpClient, d.host, d.apiKey, existingProjectID); err != nil { return fmt.Errorf("checking that the project exists: %w", err) } else if !projectFound { return fmt.Errorf("project with ID %q not found", existingProjectID) @@ -163,7 +167,7 @@ func (d *SBOMUploader) Do(_ context.Context) error { values["project"] = strings.NewReader(d.projectID) } - _, err := uploadSBOMRequest(d.host, d.apiKey, values) + _, err := uploadSBOMRequest(d.httpClient, d.host, d.apiKey, values) return err } @@ -201,7 +205,7 @@ type teamPermissionsResponse struct { } } -func teamPermissionsRequest(host *url.URL, apiKey string) (*teamPermissionsResponse, error) { +func teamPermissionsRequest(httpClient *http.Client, host *url.URL, apiKey string) (*teamPermissionsResponse, error) { apiEndpoint := host.JoinPath("/api/v1/team/self") req, err := http.NewRequest(http.MethodGet, apiEndpoint.String(), nil) @@ -211,7 +215,7 @@ func teamPermissionsRequest(host *url.URL, apiKey string) (*teamPermissionsRespo req.Header.Set("X-Api-Key", apiKey) // Submit the request - res, err := http.DefaultClient.Do(req) + res, err := httpClient.Do(req) if err != nil { return nil, err } @@ -241,7 +245,7 @@ type uploadSBOMResponse struct { Token string } -func uploadSBOMRequest(host *url.URL, apiKey string, values map[string]io.Reader) (*uploadSBOMResponse, error) { +func uploadSBOMRequest(httpClient *http.Client, host *url.URL, apiKey string, values map[string]io.Reader) (*uploadSBOMResponse, error) { // Prepare the form-data var b bytes.Buffer w := multipart.NewWriter(&b) @@ -275,7 +279,7 @@ func uploadSBOMRequest(host *url.URL, apiKey string, values map[string]io.Reader req.Header.Set("X-Api-Key", apiKey) // Submit the request - res, err := http.DefaultClient.Do(req) + res, err := httpClient.Do(req) if err != nil { return nil, err } @@ -301,7 +305,7 @@ func uploadSBOMRequest(host *url.URL, apiKey string, values map[string]io.Reader // We are listing projects instead of accessing a specific one to enable // son in the future listing and selection in the UI -func projectExists(host *url.URL, apiKey string, projectID string) (bool, error) { +func projectExists(httpClient *http.Client, host *url.URL, apiKey string, projectID string) (bool, error) { apiEndpoint := host.JoinPath(fmt.Sprintf("/api/v1/project/%s", projectID)) req, err := http.NewRequest(http.MethodGet, apiEndpoint.String(), nil) @@ -311,7 +315,7 @@ func projectExists(host *url.URL, apiKey string, projectID string) (bool, error) req.Header.Set("X-Api-Key", apiKey) // Submit the request - res, err := http.DefaultClient.Do(req) + res, err := httpClient.Do(req) if err != nil { return false, err } diff --git a/app/controlplane/plugins/core/dependency-track/v1/client/sbom_test.go b/app/controlplane/plugins/core/dependency-track/v1/client/sbom_test.go index 5bd48bc25..d0b1ddecd 100644 --- a/app/controlplane/plugins/core/dependency-track/v1/client/sbom_test.go +++ b/app/controlplane/plugins/core/dependency-track/v1/client/sbom_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package client import ( "bytes" "io" + "net/http" "net/url" "testing" @@ -52,7 +53,7 @@ func TestNewSBOMUploader(t *testing.T) { assert := assert.New(t) for _, tc := range tests { - got, err := NewSBOMUploader(tc.hostname, tc.apiKey, tc.sbom, tc.projectID, tc.projectName, tc.parentID) + got, err := NewSBOMUploader(http.DefaultClient, tc.hostname, tc.apiKey, tc.sbom, tc.projectID, tc.projectName, tc.parentID) if tc.wantError { assert.Error(err) continue @@ -62,8 +63,9 @@ func TestNewSBOMUploader(t *testing.T) { assert.NoError(err) assert.EqualValues(&SBOMUploader{ &base{ - apiKey: tc.apiKey, - host: uri, + apiKey: tc.apiKey, + host: uri, + httpClient: http.DefaultClient, }, tc.sbom, tc.projectID, tc.projectName, @@ -90,7 +92,7 @@ func TestNewChecker(t *testing.T) { assert := assert.New(t) for _, tc := range tests { - got, err := NewIntegration(tc.hostname, tc.apiKey, tc.autoCreate) + got, err := NewIntegration(http.DefaultClient, tc.hostname, tc.apiKey, tc.autoCreate) if tc.wantError { assert.Error(err) continue @@ -100,8 +102,9 @@ func TestNewChecker(t *testing.T) { assert.NoError(err) assert.EqualValues(&Integration{ base: &base{ - apiKey: tc.apiKey, - host: uri, + apiKey: tc.apiKey, + host: uri, + httpClient: http.DefaultClient, }, checkAutoCreate: tc.autoCreate, }, got) diff --git a/app/controlplane/plugins/core/dependency-track/v1/cmd/main.go b/app/controlplane/plugins/core/dependency-track/v1/cmd/main.go index 28ab5c2c3..390f00914 100644 --- a/app/controlplane/plugins/core/dependency-track/v1/cmd/main.go +++ b/app/controlplane/plugins/core/dependency-track/v1/cmd/main.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,14 +17,21 @@ package main import ( "log" + kratoslog "github.com/go-kratos/kratos/v2/log" + dependencytrack "github.com/chainloop-dev/chainloop/app/controlplane/plugins/core/dependency-track/v1" + "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1/plugin" ) // Plugin entrypoint func main() { if err := plugin.Serve(&plugin.ServeOpts{ - Factory: dependencytrack.New, + // Served as a standalone process, this plugin gets no configuration + // from the control plane, so it keeps the default network policy. + Factory: func(l kratoslog.Logger) (sdk.FanOut, error) { + return dependencytrack.New(l, sdk.NetworkPolicy{}) + }, }); err != nil { log.Fatal(err) } diff --git a/app/controlplane/plugins/core/dependency-track/v1/extension.go b/app/controlplane/plugins/core/dependency-track/v1/extension.go index 767463cd7..3d251743a 100644 --- a/app/controlplane/plugins/core/dependency-track/v1/extension.go +++ b/app/controlplane/plugins/core/dependency-track/v1/extension.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "text/template" @@ -32,6 +33,7 @@ import ( type DependencyTrack struct { *sdk.FanOutIntegration + httpClient *http.Client } // Request schemas for both registration and attachment @@ -77,7 +79,12 @@ type attachmentConfig struct { const description = "Send CycloneDX SBOMs to your Dependency-Track instance" -func New(l log.Logger) (sdk.FanOut, error) { +// New initializes the Dependency-Track integration. +// +// The instance URL is supplied at registration, and a deployment commonly runs +// Dependency-Track inside its own network, so whether a non-public instance is +// reachable is left to netPolicy. +func New(l log.Logger, netPolicy sdk.NetworkPolicy) (sdk.FanOut, error) { base, err := sdk.NewFanOut( &sdk.NewParams{ ID: "dependency-track", @@ -94,7 +101,11 @@ func New(l log.Logger) (sdk.FanOut, error) { return nil, err } - return &DependencyTrack{base}, nil + // SBOM uploads can be large, so the client keeps no total request + // timeout, matching the http.DefaultClient it replaces. + httpClient := sdk.NewHTTPClient(sdk.HTTPClientOptions{PublicTargetsOnly: netPolicy.BlockPrivateTargets}) + + return &DependencyTrack{FanOutIntegration: base, httpClient: httpClient}, nil } func (i *DependencyTrack) Register(ctx context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) { @@ -107,7 +118,7 @@ func (i *DependencyTrack) Register(ctx context.Context, req *sdk.RegistrationReq // Validate that the provided configuration is valid instance, enableProjectCreation := request.InstanceURI, request.AllowAutoCreate - checker, err := client.NewIntegration(instance, request.APIKey, enableProjectCreation) + checker, err := client.NewIntegration(i.httpClient, instance, request.APIKey, enableProjectCreation) if err != nil { return nil, fmt.Errorf("checking integration: %w", err) } @@ -146,7 +157,7 @@ func (i *DependencyTrack) Attach(ctx context.Context, req *sdk.AttachmentRequest return nil, fmt.Errorf("invalid registration configuration: %w", err) } - if err := validateAttachment(ctx, rc, &request, req.RegistrationInfo.Credentials); err != nil { + if err := validateAttachment(ctx, i.httpClient, rc, &request, req.RegistrationInfo.Credentials); err != nil { return nil, fmt.Errorf("invalid attachment configuration: %w", err) } @@ -166,7 +177,7 @@ func (i *DependencyTrack) Execute(ctx context.Context, req *sdk.ExecutionRequest var errs error // Iterate over all SBOMs for _, sbom := range req.Input.Materials { - if err := doExecute(ctx, req, sbom, i.Logger); err != nil { + if err := doExecute(ctx, i.httpClient, req, sbom, i.Logger); err != nil { errs = errors.Join(errs, err) continue } @@ -179,7 +190,7 @@ func (i *DependencyTrack) Execute(ctx context.Context, req *sdk.ExecutionRequest return nil } -func doExecute(ctx context.Context, req *sdk.ExecutionRequest, sbom *sdk.ExecuteMaterial, l *log.Helper) error { +func doExecute(ctx context.Context, httpClient *http.Client, req *sdk.ExecutionRequest, sbom *sdk.ExecuteMaterial, l *log.Helper) error { l.Info("execution requested") // Make sure it's an SBOM and all the required configuration has been received @@ -226,7 +237,8 @@ func doExecute(ctx context.Context, req *sdk.ExecutionRequest, sbom *sdk.Execute ) // Create an SBOM client and perform validation and upload - d, err := client.NewSBOMUploader(registrationConfig.Domain, + d, err := client.NewSBOMUploader(httpClient, + registrationConfig.Domain, req.RegistrationInfo.Credentials.Password, bytes.NewReader(sbom.Content), attachmentConfig.ProjectID, @@ -334,13 +346,13 @@ func resolveProjectName(projectNameTpl string, attAnnotations, sbomAnnotations m // i.e we want to attach to a dependency track integration and we are proving the right attachment options // Not only syntactically but also semantically, i.e we can only request auto-creation of projects if the integration allows it -func validateAttachment(ctx context.Context, rc *registrationConfig, ac *attachmentRequest, credentials *sdk.Credentials) error { +func validateAttachment(ctx context.Context, httpClient *http.Client, rc *registrationConfig, ac *attachmentRequest, credentials *sdk.Credentials) error { if err := validateAttachmentConfiguration(rc, ac); err != nil { return fmt.Errorf("validating attachment configuration: %w", err) } // Instantiate an actual client to see if it would work with the current configuration - d, err := client.NewSBOMUploader(rc.Domain, credentials.Password, nil, ac.ProjectID, ac.ProjectName, ac.ParentID) + d, err := client.NewSBOMUploader(httpClient, rc.Domain, credentials.Password, nil, ac.ProjectID, ac.ProjectName, ac.ParentID) if err != nil { return fmt.Errorf("creating uploader: %w", err) } diff --git a/app/controlplane/plugins/core/dependency-track/v1/extension_test.go b/app/controlplane/plugins/core/dependency-track/v1/extension_test.go index 32a3f2e45..94b972cca 100644 --- a/app/controlplane/plugins/core/dependency-track/v1/extension_test.go +++ b/app/controlplane/plugins/core/dependency-track/v1/extension_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,7 +16,10 @@ package dependencytrack import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" @@ -60,7 +63,7 @@ func TestValidateRegistrationInput(t *testing.T) { }, } - integration, err := New(nil) + integration, err := New(nil, sdk.NetworkPolicy{}) require.NoError(t, err) for _, tc := range testCases { @@ -204,7 +207,7 @@ func TestValidateAttachmentInput(t *testing.T) { }, } - integration, err := New(nil) + integration, err := New(nil, sdk.NetworkPolicy{}) require.NoError(t, err) for _, tc := range testCases { @@ -222,7 +225,7 @@ func TestValidateAttachmentInput(t *testing.T) { } func TestNewIntegration(t *testing.T) { - _, err := New(nil) + _, err := New(nil, sdk.NetworkPolicy{}) assert.NoError(t, err) } @@ -410,3 +413,58 @@ func TestVerifyAllFilters(t *testing.T) { }) } } + +// A Dependency-Track instance commonly runs inside the deployment's own +// network, so whether it is reachable is up to the deployment's network +// policy. httptest listens on the loopback interface, which stands in for +// such an instance. +func TestRegisterHonoursNetworkPolicy(t *testing.T) { + testCases := []struct { + name string + netPolicy sdk.NetworkPolicy + wantBlocked bool + }{ + { + name: "reachable by default", + netPolicy: sdk.NetworkPolicy{}, + }, + { + name: "unreachable when private targets are blocked", + netPolicy: sdk.NetworkPolicy{BlockPrivateTargets: true}, + wantBlocked: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "permissions": []map[string]string{ + {"name": "BOM_UPLOAD"}, + {"name": "VIEW_PORTFOLIO"}, + }, + })) + })) + defer server.Close() + + integration, err := New(nil, tc.netPolicy) + require.NoError(t, err) + + payload, err := json.Marshal(map[string]string{"instanceURI": server.URL, "apiKey": "an-api-key"}) + require.NoError(t, err) + + _, err = integration.Register(context.Background(), &sdk.RegistrationRequest{Payload: payload}) + + if tc.wantBlocked { + assert.ErrorIs(t, err, sdk.ErrBlockedTarget) + assert.Zero(t, requests, "the instance must not be reached") + return + } + + require.NoError(t, err) + assert.Equal(t, 1, requests) + }) + } +} diff --git a/app/controlplane/plugins/core/discord-webhook/v1/discord.go b/app/controlplane/plugins/core/discord-webhook/v1/discord.go index bc7ecf4f8..5a1283327 100644 --- a/app/controlplane/plugins/core/discord-webhook/v1/discord.go +++ b/app/controlplane/plugins/core/discord-webhook/v1/discord.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -31,6 +31,14 @@ import ( type Integration struct { *sdk.FanOutIntegration + client *http.Client +} + +// publicOnlyClient builds the HTTP client used to reach the webhook. Discord is +// a public service, so a destination inside the deployment's own network is +// always refused, whatever the deployment's plugin network policy says. +func publicOnlyClient() *http.Client { + return sdk.NewHTTPClient(sdk.HTTPClientOptions{PublicTargetsOnly: true}) } // 1 - API schema definitions @@ -55,7 +63,7 @@ func New(l log.Logger) (sdk.FanOut, error) { base, err := sdk.NewFanOut( &sdk.NewParams{ ID: "discord-webhook", - Version: "1.1", + Version: "1.2", Description: "Send attestations to Discord", Logger: l, InputSchema: &sdk.InputSchema{ @@ -69,7 +77,7 @@ func New(l log.Logger) (sdk.FanOut, error) { return nil, err } - return &Integration{base}, nil + return &Integration{FanOutIntegration: base, client: publicOnlyClient()}, nil } type webhookResponse struct { @@ -80,7 +88,7 @@ type webhookResponse struct { } // Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization -func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) { +func (i *Integration) Register(ctx context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) { i.Logger.Info("registration requested") var request *registrationRequest @@ -89,7 +97,12 @@ func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) } // Test the webhook URL and extract some information from it to use it as reference for the user - resp, err := http.Get(request.WebhookURL) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, request.WebhookURL, nil) + if err != nil { + return nil, fmt.Errorf("invalid webhook URL: %w", err) + } + + resp, err := i.client.Do(httpReq) if err != nil { return nil, fmt.Errorf("invalid webhook URL: %w", err) } @@ -146,7 +159,7 @@ func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) erro } webhookURL := req.RegistrationInfo.Credentials.Password - if err := executeWebhook(webhookURL, config.Username, []byte(summary), "New Attestation Received"); err != nil { + if err := executeWebhook(i.client, webhookURL, config.Username, []byte(summary), "New Attestation Received"); err != nil { return fmt.Errorf("error executing webhook: %w", err) } @@ -172,7 +185,7 @@ func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) erro // --boundary // Content-Disposition: form-data; name="files[0]"; filename="statement.json" // --boundary -func executeWebhook(webhookURL, usernameOverride string, statement []byte, msgContent string) error { +func executeWebhook(client *http.Client, webhookURL, usernameOverride string, statement []byte, msgContent string) error { var b bytes.Buffer multipartWriter := multipart.NewWriter(&b) @@ -215,8 +228,7 @@ func executeWebhook(webhookURL, usernameOverride string, statement []byte, msgCo // Needed to dump the content of the multipartWriter to the buffer multipartWriter.Close() - // #nosec G107 - we are using a constant API URL that is not user input at this stage - r, err := http.Post(webhookURL, multipartWriter.FormDataContentType(), &b) + r, err := client.Post(webhookURL, multipartWriter.FormDataContentType(), &b) if err != nil { return fmt.Errorf("creating request: %w", err) } diff --git a/app/controlplane/plugins/core/discord-webhook/v1/discord_test.go b/app/controlplane/plugins/core/discord-webhook/v1/discord_test.go index 65321e1d6..d72c7a13f 100644 --- a/app/controlplane/plugins/core/discord-webhook/v1/discord_test.go +++ b/app/controlplane/plugins/core/discord-webhook/v1/discord_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,7 +16,10 @@ package discord import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" @@ -91,3 +94,35 @@ func TestNewIntegration(t *testing.T) { _, err := New(nil) assert.NoError(t, err) } + +// The Discord webhook only ever targets Discord, so a destination inside the +// deployment's own network is refused. httptest listens on the loopback +// interface, which stands in for any such destination. +func TestRegisterRejectsNonPublicWebhook(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + assert.NoError(t, json.NewEncoder(w).Encode(webhookResponse{Name: "internal service"})) + })) + defer server.Close() + + integration, err := New(nil) + require.NoError(t, err) + + payload, err := json.Marshal(map[string]string{"webhook": server.URL}) + require.NoError(t, err) + + _, err = integration.Register(context.Background(), &sdk.RegistrationRequest{Payload: payload}) + assert.ErrorIs(t, err, sdk.ErrBlockedTarget) +} + +func TestExecuteWebhookRejectsNonPublicURL(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := executeWebhook(publicOnlyClient(), server.URL, "", []byte("statement"), "New Attestation Received") + assert.ErrorIs(t, err, sdk.ErrBlockedTarget) + assert.Zero(t, requests, "the webhook must not be reached") +} diff --git a/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go b/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go index 63b854b97..ecca50ae9 100644 --- a/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go +++ b/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,6 +32,14 @@ import ( type Integration struct { *sdk.FanOutIntegration + client *http.Client +} + +// publicOnlyClient builds the HTTP client used to reach the webhook. Slack is +// a public service, so a destination inside the deployment's own network is +// always refused, whatever the deployment's plugin network policy says. +func publicOnlyClient() *http.Client { + return sdk.NewHTTPClient(sdk.HTTPClientOptions{PublicTargetsOnly: true}) } // 1 - API schema definitions @@ -50,7 +58,7 @@ func New(l log.Logger) (sdk.FanOut, error) { base, err := sdk.NewFanOut( &sdk.NewParams{ ID: "slack-webhook", - Version: "1.1", + Version: "1.2", Description: "Send attestations to Slack", Logger: l, InputSchema: &sdk.InputSchema{ @@ -64,7 +72,7 @@ func New(l log.Logger) (sdk.FanOut, error) { return nil, err } - return &Integration{base}, nil + return &Integration{FanOutIntegration: base, client: publicOnlyClient()}, nil } // Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization @@ -76,7 +84,7 @@ func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) return nil, fmt.Errorf("invalid registration request: %w", err) } - if err := executeWebhook(request.WebhookURL, "This is a test message. Welcome to Chainloop!"); err != nil { + if err := executeWebhook(i.client, request.WebhookURL, "This is a test message. Welcome to Chainloop!"); err != nil { return nil, fmt.Errorf("error validating a webhook: %w", err) } @@ -118,7 +126,7 @@ func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) erro msg := fmt.Sprintf("\nNew attestation received!\n```\n%s\n```\n", summary) webhookURL := req.RegistrationInfo.Credentials.Password - if err := executeWebhook(webhookURL, msg); err != nil { + if err := executeWebhook(i.client, webhookURL, msg); err != nil { return fmt.Errorf("error executing webhook: %w", err) } @@ -127,7 +135,7 @@ func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) erro } // Send attestation to Slack -func executeWebhook(webhookURL, msgContent string) error { +func executeWebhook(client *http.Client, webhookURL, msgContent string) error { payload := map[string]string{ "text": msgContent, } @@ -138,8 +146,7 @@ func executeWebhook(webhookURL, msgContent string) error { requestBody := bytes.NewReader(jsonPayload) - // #nosec G107 - we are using a constant API URL that is not user input at this stage - r, err := http.Post(webhookURL, "application/json", requestBody) + r, err := client.Post(webhookURL, "application/json", requestBody) if err != nil { return fmt.Errorf("error making request: %w", err) } diff --git a/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook_test.go b/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook_test.go index 09188cd63..99bf14879 100644 --- a/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook_test.go +++ b/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,7 +16,10 @@ package slack import ( + "context" "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" @@ -122,3 +125,35 @@ func TestNewIntegration(t *testing.T) { _, err := New(nil) assert.NoError(t, err) } + +// The Slack webhook only ever targets Slack, so a destination inside the +// deployment's own network is refused. httptest listens on the loopback +// interface, which stands in for any such destination. +func TestExecuteWebhookRejectsNonPublicURL(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := executeWebhook(publicOnlyClient(), server.URL, "New attestation received") + assert.ErrorIs(t, err, sdk.ErrBlockedTarget) + assert.Zero(t, requests, "the webhook must not be reached") +} + +func TestRegisterRejectsNonPublicWebhook(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + integration, err := New(nil) + require.NoError(t, err) + + payload, err := json.Marshal(map[string]string{"webhook": server.URL}) + require.NoError(t, err) + + _, err = integration.Register(context.Background(), &sdk.RegistrationRequest{Payload: payload}) + assert.ErrorIs(t, err, sdk.ErrBlockedTarget) +} diff --git a/app/controlplane/plugins/core/webhook/v1/webhook.go b/app/controlplane/plugins/core/webhook/v1/webhook.go index f395afd69..f3059c666 100644 --- a/app/controlplane/plugins/core/webhook/v1/webhook.go +++ b/app/controlplane/plugins/core/webhook/v1/webhook.go @@ -69,8 +69,12 @@ type webhookPayload struct { Kind string `json:"Kind"` // e.g., "SBOM_CYCLONEDX_JSON", "ATTESTATION" } -// New initializes the webhook integration -func New(l log.Logger) (sdk.FanOut, error) { +// New initializes the webhook integration. +// +// The destination is an arbitrary URL supplied at registration, and a +// deployment may well run the receiver inside its own network, so whether a +// non-public destination is reachable is left to netPolicy. +func New(l log.Logger, netPolicy sdk.NetworkPolicy) (sdk.FanOut, error) { base, err := sdk.NewFanOut( &sdk.NewParams{ ID: "webhook", @@ -92,7 +96,10 @@ func New(l log.Logger) (sdk.FanOut, error) { return &Integration{ FanOutIntegration: base, - client: &http.Client{Timeout: perAttemptTimeout}, + client: sdk.NewHTTPClient(sdk.HTTPClientOptions{ + Timeout: perAttemptTimeout, + PublicTargetsOnly: netPolicy.BlockPrivateTargets, + }), }, nil } diff --git a/app/controlplane/plugins/core/webhook/v1/webhook_test.go b/app/controlplane/plugins/core/webhook/v1/webhook_test.go new file mode 100644 index 000000000..10138f56a --- /dev/null +++ b/app/controlplane/plugins/core/webhook/v1/webhook_test.go @@ -0,0 +1,103 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The generic webhook accepts any destination, so whether one inside the +// deployment's own network is reachable is up to the deployment's network +// policy. httptest listens on the loopback interface, which stands in for +// such a destination. +func TestRegisterHonoursNetworkPolicy(t *testing.T) { + testCases := []struct { + name string + netPolicy sdk.NetworkPolicy + wantBlocked bool + }{ + { + name: "reachable by default", + netPolicy: sdk.NetworkPolicy{}, + }, + { + name: "unreachable when private targets are blocked", + netPolicy: sdk.NetworkPolicy{BlockPrivateTargets: true}, + wantBlocked: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + integration, err := New(nil, tc.netPolicy) + require.NoError(t, err) + + payload, err := json.Marshal(map[string]string{"url": server.URL}) + require.NoError(t, err) + + _, err = integration.Register(context.Background(), &sdk.RegistrationRequest{Payload: payload}) + + if tc.wantBlocked { + assert.ErrorIs(t, err, sdk.ErrBlockedTarget) + assert.Zero(t, requests, "the webhook must not be reached") + return + } + + require.NoError(t, err) + assert.Equal(t, 1, requests) + }) + } +} + +func TestValidateURL(t *testing.T) { + testCases := []struct { + name string + url string + wantErr bool + }{ + {name: "http", url: "http://example.com/hook"}, + {name: "https", url: "https://example.com/hook"}, + {name: "unsupported scheme", url: "file:///etc/passwd", wantErr: true}, + {name: "not a URL", url: "example.com", wantErr: true}, + {name: "empty", url: "", wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := validateURL(tc.url) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/app/controlplane/plugins/plugins.go b/app/controlplane/plugins/plugins.go index c9275cdfc..a94e61e47 100644 --- a/app/controlplane/plugins/plugins.go +++ b/app/controlplane/plugins/plugins.go @@ -63,15 +63,19 @@ type goPluginInitializer struct{} // a) Plugins implemented with go-plugin, compiled as a separate binary and placed in pluginsDir // b) Built-in plugins implemented as a go modules and loaded in memory // Important: Plugins have precedence over built-in plugins -func Load(pluginsDir string, l log.Logger) (plugins sdk.AvailablePlugins, err error) { +// +// netPolicy applies to the plugins whose destination is an arbitrary URL taken +// from their registration config. Plugins whose destination is a known public +// service enforce their own, stricter policy. +func Load(pluginsDir string, netPolicy sdk.NetworkPolicy, l log.Logger) (plugins sdk.AvailablePlugins, err error) { // Array of built-in plugins to enable which are loaded in host memory dynamically toEnableBuiltIn := []sdk.FanOutFactory{ - dependencytrack.New, + func(l log.Logger) (sdk.FanOut, error) { return dependencytrack.New(l, netPolicy) }, smtp.New, discord.New, guac.New, slack.New, - webhook.New, + func(l log.Logger) (sdk.FanOut, error) { return webhook.New(l, netPolicy) }, } // Load plugins in memory from the array above diff --git a/app/controlplane/plugins/sdk/readme-generator/main.go b/app/controlplane/plugins/sdk/readme-generator/main.go index d6fdb1915..1444ce8ae 100644 --- a/app/controlplane/plugins/sdk/readme-generator/main.go +++ b/app/controlplane/plugins/sdk/readme-generator/main.go @@ -42,7 +42,7 @@ var integrationsIndexPath string func mainE() error { l := log.NewStdLogger(os.Stdout) - plugins, err := plugins.Load("", l) + plugins, err := plugins.Load("", sdk.NetworkPolicy{}, l) if err != nil { return fmt.Errorf("failed to load plugins: %w", err) } diff --git a/app/controlplane/plugins/sdk/v1/httpclient.go b/app/controlplane/plugins/sdk/v1/httpclient.go new file mode 100644 index 000000000..6fe1efb93 --- /dev/null +++ b/app/controlplane/plugins/sdk/v1/httpclient.go @@ -0,0 +1,194 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sdk + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "time" +) + +// ErrBlockedTarget is returned when a request is refused because its +// destination is not allowed by the client's network policy. +var ErrBlockedTarget = errors.New("blocked outbound request") + +const ( + dialTimeout = 10 * time.Second + dialKeepAlive = 30 * time.Second + tlsHandshakeWait = 10 * time.Second + idleConnTimeout = 90 * time.Second +) + +// NetworkPolicy carries the outbound restrictions a deployment puts on the +// plugins whose destination is an arbitrary URL taken from their registration +// config. It mirrors the control plane's plugins_network_policy config. +type NetworkPolicy struct { + // BlockPrivateTargets refuses destinations that are not publicly + // routable. It is off by default, because a deployment may well run the + // service a plugin talks to inside its own network. + BlockPrivateTargets bool +} + +// HTTPClientOptions configures the client returned by NewHTTPClient. +type HTTPClientOptions struct { + // Timeout caps the total duration of a single request, including + // redirects and reading the response body. Zero leaves the client + // without a timeout. + Timeout time.Duration + + // PublicTargetsOnly refuses to connect to destinations that are not + // publicly routable: loopback, private ranges, link-local addresses + // (where cloud metadata services live) and the IPv6 transition ranges + // that embed an IPv4 address. + // + // Enable it for plugins whose destination is a well-known public service. + // Plugins that legitimately talk to hosts inside the deployment's own + // network must leave it disabled. + PublicTargetsOnly bool +} + +// NewHTTPClient builds the HTTP client a plugin should use for requests to a +// destination taken from its registration config. +func NewHTTPClient(opts HTTPClientOptions) *http.Client { + dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive} + dial := dialer.DialContext + + if opts.PublicTargetsOnly { + dial = publicOnlyDialContext(net.DefaultResolver.LookupIPAddr, dial) + } + + return &http.Client{ + Timeout: opts.Timeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dial, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: idleConnTimeout, + TLSHandshakeTimeout: tlsHandshakeWait, + ExpectContinueTimeout: 1 * time.Second, + }, + } +} + +type resolveFunc func(ctx context.Context, host string) ([]net.IPAddr, error) + +type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + +// publicOnlyDialContext wraps dial so that a connection is only made to a +// publicly routable address. +// +// The check runs here, at dial time, rather than against the URL, for two +// reasons. It sees the address the connection will actually use, so a host +// name that resolves to an allowed address for a check and to a blocked one +// for the connection cannot slip through: the dial targets the very IP that +// was validated. And because every redirect hop opens its own connection, +// the whole chain is covered, not just the URL the caller supplied. +func publicOnlyDialContext(resolve resolveFunc, dial dialFunc) dialFunc { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("%w: malformed address %q", ErrBlockedTarget, addr) + } + + ips, err := resolve(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolving %q: %w", host, err) + } + + if len(ips) == 0 { + return nil, fmt.Errorf("%w: %q did not resolve to any address", ErrBlockedTarget, host) + } + + // A host that answers with a mix of public and non-public addresses is + // refused outright, so that retrying cannot land on the blocked one. + for _, ip := range ips { + if !isPubliclyRoutable(ip.IP) { + return nil, fmt.Errorf("%w: %q resolves to non-public address %s", ErrBlockedTarget, host, ip.IP) + } + } + + var lastErr error + for _, ip := range ips { + conn, err := dial(ctx, network, net.JoinHostPort(ip.IP.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + + return nil, lastErr + } +} + +// blockedNets holds the ranges that are not publicly routable but that the +// checks net.IP offers do not already cover. +var blockedNets = []*net.IPNet{ + mustParseCIDR("100.64.0.0/10"), // RFC 6598 shared address space (CGNAT) + mustParseCIDR("192.0.0.0/24"), // RFC 6890 IETF protocol assignments + mustParseCIDR("198.18.0.0/15"), // RFC 2544 benchmarking + mustParseCIDR("240.0.0.0/4"), // RFC 1112 reserved + mustParseCIDR("::/96"), // RFC 4291 IPv4-compatible, deprecated + mustParseCIDR("64:ff9b::/96"), // RFC 6052 NAT64 + mustParseCIDR("2001::/32"), // RFC 4380 Teredo + mustParseCIDR("2002::/16"), // RFC 3056 6to4 +} + +// isPubliclyRoutable reports whether ip is an address on the public internet. +func isPubliclyRoutable(ip net.IP) bool { + if ip == nil { + return false + } + + // Covers the unspecified address, loopback, link-local addresses (and with + // them the cloud metadata endpoints), multicast and the IPv4 broadcast + // address. + if !ip.IsGlobalUnicast() { + return false + } + + // RFC 1918 ranges and RFC 4193 unique local addresses. + if ip.IsPrivate() { + return false + } + + // An IPv4 address reached through an IPv6 form is judged as the IPv4 + // address it carries. The checks above already do this; the ranges below + // are written in their IPv4 form, so narrow the address first. + if v4 := ip.To4(); v4 != nil { + ip = v4 + } + + for _, blocked := range blockedNets { + if blocked.Contains(ip) { + return false + } + } + + return true +} + +func mustParseCIDR(cidr string) *net.IPNet { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + panic(fmt.Sprintf("sdk: invalid CIDR %q: %v", cidr, err)) + } + + return network +} diff --git a/app/controlplane/plugins/sdk/v1/httpclient_test.go b/app/controlplane/plugins/sdk/v1/httpclient_test.go new file mode 100644 index 000000000..b9a966341 --- /dev/null +++ b/app/controlplane/plugins/sdk/v1/httpclient_test.go @@ -0,0 +1,229 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sdk + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsPubliclyRoutable(t *testing.T) { + testCases := []struct { + name string + ip string + want bool + }{ + {name: "public IPv4", ip: "8.8.8.8", want: true}, + {name: "public IPv6", ip: "2606:4700:4700::1111", want: true}, + + {name: "IPv4 loopback", ip: "127.0.0.1", want: false}, + {name: "IPv6 loopback", ip: "::1", want: false}, + {name: "unspecified IPv4", ip: "0.0.0.0", want: false}, + {name: "unspecified IPv6", ip: "::", want: false}, + {name: "IPv4 broadcast", ip: "255.255.255.255", want: false}, + {name: "IPv4 multicast", ip: "224.0.0.1", want: false}, + + {name: "RFC 1918 10/8", ip: "10.0.0.1", want: false}, + {name: "RFC 1918 172.16/12", ip: "172.16.0.1", want: false}, + {name: "RFC 1918 192.168/16", ip: "192.168.1.1", want: false}, + {name: "IPv6 unique local", ip: "fc00::1", want: false}, + + // Cloud metadata services live in the link-local range + {name: "AWS and Azure IMDS", ip: "169.254.169.254", want: false}, + {name: "IPv6 link-local", ip: "fe80::1", want: false}, + + {name: "CGNAT shared address space", ip: "100.64.0.1", want: false}, + {name: "IETF protocol assignments", ip: "192.0.0.1", want: false}, + {name: "benchmarking range", ip: "198.18.0.1", want: false}, + {name: "reserved 240/4", ip: "240.0.0.1", want: false}, + + // An IPv4 address reached through an IPv6 form must be judged as the + // IPv4 address it carries. + {name: "IPv4-mapped loopback", ip: "::ffff:127.0.0.1", want: false}, + {name: "IPv4-mapped private", ip: "::ffff:10.0.0.1", want: false}, + {name: "IPv4-mapped IMDS", ip: "::ffff:169.254.169.254", want: false}, + {name: "IPv4-mapped public", ip: "::ffff:8.8.8.8", want: true}, + {name: "IPv4-compatible", ip: "::10.0.0.1", want: false}, + + // IPv6 transition ranges embed an IPv4 address, so they are rejected + // wholesale rather than unwrapped. + {name: "NAT64", ip: "64:ff9b::a00:1", want: false}, + {name: "Teredo", ip: "2001::1", want: false}, + {name: "6to4", ip: "2002::1", want: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ip := net.ParseIP(tc.ip) + require.NotNil(t, ip, "invalid test fixture %q", tc.ip) + + assert.Equal(t, tc.want, isPubliclyRoutable(ip)) + }) + } +} + +func TestIsPubliclyRoutableRejectsNilIP(t *testing.T) { + assert.False(t, isPubliclyRoutable(nil)) +} + +func TestPublicOnlyDialContext(t *testing.T) { + testCases := []struct { + name string + // resolved is what DNS returns for the dialed host + resolved []string + // wantDialedTo is the address the underlying dialer must receive. The + // client has to connect to the IP that was validated, not to the + // hostname, so that a second DNS answer cannot change the target. + wantDialedTo string + wantBlocked bool + }{ + { + name: "public address is dialed by IP", + resolved: []string{"8.8.8.8"}, + wantDialedTo: "8.8.8.8:443", + }, + { + name: "private address is blocked", + resolved: []string{"10.0.0.1"}, + wantBlocked: true, + }, + { + name: "metadata endpoint is blocked", + resolved: []string{"169.254.169.254"}, + wantBlocked: true, + }, + { + name: "blocked when any answer is non-public", + resolved: []string{"8.8.8.8", "127.0.0.1"}, + wantBlocked: true, + }, + { + name: "first public answer is used", + resolved: []string{"1.1.1.1", "8.8.8.8"}, + wantDialedTo: "1.1.1.1:443", + }, + { + name: "host without any answer is blocked", + resolved: []string{}, + wantBlocked: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolve := func(_ context.Context, _ string) ([]net.IPAddr, error) { + addrs := make([]net.IPAddr, 0, len(tc.resolved)) + for _, ip := range tc.resolved { + addrs = append(addrs, net.IPAddr{IP: net.ParseIP(ip)}) + } + return addrs, nil + } + + var dialedTo string + dial := func(_ context.Context, _, addr string) (net.Conn, error) { + dialedTo = addr + return nil, nil + } + + _, err := publicOnlyDialContext(resolve, dial)(context.Background(), "tcp", "example.com:443") + + if tc.wantBlocked { + require.ErrorIs(t, err, ErrBlockedTarget) + assert.Empty(t, dialedTo, "a blocked target must not be dialed") + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantDialedTo, dialedTo) + }) + } +} + +func TestPublicOnlyDialContextResolutionFailure(t *testing.T) { + resolve := func(_ context.Context, _ string) ([]net.IPAddr, error) { + return nil, errors.New("no such host") + } + dial := func(_ context.Context, _, _ string) (net.Conn, error) { + return nil, errors.New("dial must not be reached") + } + + _, err := publicOnlyDialContext(resolve, dial)(context.Background(), "tcp", "example.com:443") + require.Error(t, err) +} + +func TestNewHTTPClientPublicTargetsOnly(t *testing.T) { + // httptest listens on the loopback interface, so it stands in for any + // destination a public-only client must refuse to reach. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + testCases := []struct { + name string + publicTargetsOnly bool + wantBlocked bool + }{ + {name: "public-only client refuses a loopback target", publicTargetsOnly: true, wantBlocked: true}, + {name: "unrestricted client reaches a loopback target", publicTargetsOnly: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client := NewHTTPClient(HTTPClientOptions{PublicTargetsOnly: tc.publicTargetsOnly}) + + resp, err := client.Get(server.URL) + if tc.wantBlocked { + require.ErrorIs(t, err, ErrBlockedTarget) + return + } + + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + } +} + +// Each redirect hop opens its own connection, so the dial-time check applies +// to the whole chain and not only to the URL the caller supplied. +func TestNewHTTPClientValidatesRedirectHops(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + var redirectHops int + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectHops++ + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirector.Close() + + resp, err := NewHTTPClient(HTTPClientOptions{}).Get(redirector.URL) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, 1, redirectHops) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/deployment/chainloop/Chart.yaml b/deployment/chainloop/Chart.yaml index ae34762c3..645b3da39 100644 --- a/deployment/chainloop/Chart.yaml +++ b/deployment/chainloop/Chart.yaml @@ -7,7 +7,7 @@ description: Chainloop is an open source software supply chain control plane, a type: application # Bump the patch (not minor, not major) version on each change in the Chart Source code -version: 1.434.0 +version: 1.434.1 # Do not update appVersion, this is handled automatically by the release process appVersion: v1.108.3 diff --git a/deployment/chainloop/README.md b/deployment/chainloop/README.md index 976984545..c77a8fccb 100644 --- a/deployment/chainloop/README.md +++ b/deployment/chainloop/README.md @@ -570,6 +570,8 @@ Once done, you can access with [two predefined users](https://github.com/chainlo | `controlplane.enableProfiler` | Enable pprof profiling on port 6060 | `false` | | `controlplane.tls.existingSecret` | Existing secret name containing TLS certificate to be used by the controlplane grpc server. NOTE: When it's set it will disable secret creation. The secret must contains 2 keys: tls.crt and tls.key respectively containing the certificate and private key. | `""` | | `controlplane.pluginsDir` | Directory where to look for plugins | `/plugins` | +| `controlplane.pluginsNetworkPolicy` | Outbound network policy for the plugins that accept an arbitrary target URL, currently the generic webhook and Dependency-Track plugins. Plugins whose destination is a known public service, such as the Slack and Discord webhooks, always refuse non-public destinations. | | +| `controlplane.pluginsNetworkPolicy.blockPrivateTargets` | Refuse requests to destinations that are not publicly routable, such as loopback, private ranges and cloud metadata endpoints | `false` | | `controlplane.federatedAuthentication` | Enable federated authentication during attestation process | | | `controlplane.federatedAuthentication.enabled` | Enable federated authentication | `false` | | `controlplane.federatedAuthentication.url` | URL of the federated authentication endpoint | `""` | diff --git a/deployment/chainloop/templates/controlplane/configmap.yaml b/deployment/chainloop/templates/controlplane/configmap.yaml index 3517d2593..d6bfdfa51 100644 --- a/deployment/chainloop/templates/controlplane/configmap.yaml +++ b/deployment/chainloop/templates/controlplane/configmap.yaml @@ -48,6 +48,8 @@ data: default_entry_max_size: {{ .Values.cas.defaultMaxEntrySize | quote }} {{- end }} plugins_dir: {{ .Values.controlplane.pluginsDir }} + plugins_network_policy: + block_private_targets: {{ .Values.controlplane.pluginsNetworkPolicy.blockPrivateTargets }} restrict_org_creation: {{ .Values.controlplane.restrictOrgCreation }} {{- if .Values.controlplane.uiDashboardURL }} ui_dashboard_url: {{ .Values.controlplane.uiDashboardURL | quote }} diff --git a/deployment/chainloop/values.yaml b/deployment/chainloop/values.yaml index b7779df0f..1278cebee 100644 --- a/deployment/chainloop/values.yaml +++ b/deployment/chainloop/values.yaml @@ -166,6 +166,11 @@ controlplane: ## @param controlplane.pluginsDir Directory where to look for plugins pluginsDir: /plugins + ## @extra controlplane.pluginsNetworkPolicy Outbound network policy for the plugins that accept an arbitrary target URL, currently the generic webhook and Dependency-Track plugins. Plugins whose destination is a known public service, such as the Slack and Discord webhooks, always refuse non-public destinations. + ## @param controlplane.pluginsNetworkPolicy.blockPrivateTargets Refuse requests to destinations that are not publicly routable, such as loopback, private ranges and cloud metadata endpoints + pluginsNetworkPolicy: + blockPrivateTargets: false + ## @extra controlplane.federatedAuthentication Enable federated authentication during attestation process ## @param controlplane.federatedAuthentication.enabled Enable federated authentication ## @param controlplane.federatedAuthentication.url URL of the federated authentication endpoint diff --git a/devel/integrations.md b/devel/integrations.md index 3635bb2e0..e7858c85e 100644 --- a/devel/integrations.md +++ b/devel/integrations.md @@ -11,9 +11,9 @@ Below you can find the list of currently available integrations. If you can't fi | ID | Version | Description | Material Requirement | | --- | --- | --- | --- | | [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.7 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON | -| [discord-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/discord-webhook/v1/README.md) | 1.1 | Send attestations to Discord | | +| [discord-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/discord-webhook/v1/README.md) | 1.2 | Send attestations to Discord | | | [guac](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/guac/v1/README.md) | 1.0 | Export Attestation and SBOMs metadata to a blob storage backend so guacsec/guac can consume it | SBOM_CYCLONEDX_JSON, SBOM_SPDX_JSON | -| [slack-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/slack-webhook/v1/README.md) | 1.1 | Send attestations to Slack | | +| [slack-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/slack-webhook/v1/README.md) | 1.2 | Send attestations to Slack | | | [smtp](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/smtp/v1/README.md) | 1.0 | Send emails with information about a received attestation | | | [webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/webhook/v1/README.md) | 1.2 | Send Attestation and SBOMs to a generic POST webhook URL | SBOM_CYCLONEDX_JSON, SBOM_SPDX_JSON | From 63898f3a14da1f0aaeb7ad6be9de2250404620c2 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Mon, 7 Sep 2026 12:10:06 +0200 Subject: [PATCH 2/2] fix(plugins): close SSRF guard bypass through an environment proxy A public-only client kept http.ProxyFromEnvironment, so with HTTP_PROXY or HTTPS_PROXY set the only address it connected to was the proxy's own. The dial-time check validated that address instead of the destination, leaving the guard unenforced. Such a client now uses no proxy. Also completes the set of ranges that are not publicly routable with the "this network", documentation, discard-only and IPv6 benchmarking blocks, and replaces the redirect test, which built an unrestricted client and so proved only that redirects are followed, with one that exercises the guard across a redirect chain. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 481e7cbc-976a-4f8a-ad64-9f3ee9a643d6 --- app/controlplane/plugins/sdk/v1/httpclient.go | 58 ++++++++----- .../plugins/sdk/v1/httpclient_test.go | 81 ++++++++++++++++--- 2 files changed, 107 insertions(+), 32 deletions(-) diff --git a/app/controlplane/plugins/sdk/v1/httpclient.go b/app/controlplane/plugins/sdk/v1/httpclient.go index 6fe1efb93..29ef48a8c 100644 --- a/app/controlplane/plugins/sdk/v1/httpclient.go +++ b/app/controlplane/plugins/sdk/v1/httpclient.go @@ -57,6 +57,10 @@ type HTTPClientOptions struct { // (where cloud metadata services live) and the IPv6 transition ranges // that embed an IPv4 address. // + // Such a client also ignores any proxy configured in the environment, + // which would otherwise be the only address it connects to and would + // leave the destination unchecked. + // // Enable it for plugins whose destination is a well-known public service. // Plugins that legitimately talk to hosts inside the deployment's own // network must leave it disabled. @@ -67,24 +71,27 @@ type HTTPClientOptions struct { // destination taken from its registration config. func NewHTTPClient(opts HTTPClientOptions) *http.Client { dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive} - dial := dialer.DialContext - if opts.PublicTargetsOnly { - dial = publicOnlyDialContext(net.DefaultResolver.LookupIPAddr, dial) + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: idleConnTimeout, + TLSHandshakeTimeout: tlsHandshakeWait, + ExpectContinueTimeout: 1 * time.Second, } - return &http.Client{ - Timeout: opts.Timeout, - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: dial, - ForceAttemptHTTP2: true, - MaxIdleConns: 100, - IdleConnTimeout: idleConnTimeout, - TLSHandshakeTimeout: tlsHandshakeWait, - ExpectContinueTimeout: 1 * time.Second, - }, + if opts.PublicTargetsOnly { + transport.DialContext = publicOnlyDialContext(net.DefaultResolver.LookupIPAddr, dialer.DialContext) + + // Through a proxy the only address this client connects to is the + // proxy's own, which leaves the destination unchecked and the guard + // above unenforced, so a public-only client never uses one. + transport.Proxy = nil } + + return &http.Client{Timeout: opts.Timeout, Transport: transport} } type resolveFunc func(ctx context.Context, host string) ([]net.IPAddr, error) @@ -140,14 +147,21 @@ func publicOnlyDialContext(resolve resolveFunc, dial dialFunc) dialFunc { // blockedNets holds the ranges that are not publicly routable but that the // checks net.IP offers do not already cover. var blockedNets = []*net.IPNet{ - mustParseCIDR("100.64.0.0/10"), // RFC 6598 shared address space (CGNAT) - mustParseCIDR("192.0.0.0/24"), // RFC 6890 IETF protocol assignments - mustParseCIDR("198.18.0.0/15"), // RFC 2544 benchmarking - mustParseCIDR("240.0.0.0/4"), // RFC 1112 reserved - mustParseCIDR("::/96"), // RFC 4291 IPv4-compatible, deprecated - mustParseCIDR("64:ff9b::/96"), // RFC 6052 NAT64 - mustParseCIDR("2001::/32"), // RFC 4380 Teredo - mustParseCIDR("2002::/16"), // RFC 3056 6to4 + mustParseCIDR("0.0.0.0/8"), // RFC 6890 "this network" + mustParseCIDR("100.64.0.0/10"), // RFC 6598 shared address space (CGNAT) + mustParseCIDR("192.0.0.0/24"), // RFC 6890 IETF protocol assignments + mustParseCIDR("192.0.2.0/24"), // RFC 5737 documentation + mustParseCIDR("198.18.0.0/15"), // RFC 2544 benchmarking + mustParseCIDR("198.51.100.0/24"), // RFC 5737 documentation + mustParseCIDR("203.0.113.0/24"), // RFC 5737 documentation + mustParseCIDR("240.0.0.0/4"), // RFC 1112 reserved + mustParseCIDR("::/96"), // RFC 4291 IPv4-compatible, deprecated + mustParseCIDR("64:ff9b::/96"), // RFC 6052 NAT64 + mustParseCIDR("100::/64"), // RFC 6666 discard-only + mustParseCIDR("2001::/32"), // RFC 4380 Teredo + mustParseCIDR("2001:2::/48"), // RFC 5180 benchmarking + mustParseCIDR("2001:db8::/32"), // RFC 3849 documentation + mustParseCIDR("2002::/16"), // RFC 3056 6to4 } // isPubliclyRoutable reports whether ip is an address on the public internet. diff --git a/app/controlplane/plugins/sdk/v1/httpclient_test.go b/app/controlplane/plugins/sdk/v1/httpclient_test.go index b9a966341..f4f0498c0 100644 --- a/app/controlplane/plugins/sdk/v1/httpclient_test.go +++ b/app/controlplane/plugins/sdk/v1/httpclient_test.go @@ -56,6 +56,13 @@ func TestIsPubliclyRoutable(t *testing.T) { {name: "IETF protocol assignments", ip: "192.0.0.1", want: false}, {name: "benchmarking range", ip: "198.18.0.1", want: false}, {name: "reserved 240/4", ip: "240.0.0.1", want: false}, + {name: "this network 0/8", ip: "0.1.2.3", want: false}, + {name: "IPv4 documentation 192.0.2/24", ip: "192.0.2.1", want: false}, + {name: "IPv4 documentation 198.51.100/24", ip: "198.51.100.1", want: false}, + {name: "IPv4 documentation 203.0.113/24", ip: "203.0.113.1", want: false}, + {name: "IPv6 discard-only", ip: "100::1", want: false}, + {name: "IPv6 benchmarking", ip: "2001:2::1", want: false}, + {name: "IPv6 documentation", ip: "2001:db8::1", want: false}, // An IPv4 address reached through an IPv6 form must be judged as the // IPv4 address it carries. @@ -205,25 +212,79 @@ func TestNewHTTPClientPublicTargetsOnly(t *testing.T) { } } -// Each redirect hop opens its own connection, so the dial-time check applies -// to the whole chain and not only to the URL the caller supplied. -func TestNewHTTPClientValidatesRedirectHops(t *testing.T) { +// A proxy would be the only address a public-only client connects to, leaving +// the destination unchecked, so such a client must not pick one up from the +// environment. Asserted on the transport because net/http resolves the +// environment once per process, which a test cannot change after the fact. +func TestNewHTTPClientProxyUse(t *testing.T) { + testCases := []struct { + name string + publicTargetsOnly bool + wantProxy bool + }{ + {name: "public-only client ignores an environment proxy", publicTargetsOnly: true}, + {name: "unrestricted client keeps an environment proxy", wantProxy: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client := NewHTTPClient(HTTPClientOptions{PublicTargetsOnly: tc.publicTargetsOnly}) + + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + + if tc.wantProxy { + assert.NotNil(t, transport.Proxy) + return + } + assert.Nil(t, transport.Proxy) + }) + } +} + +// Each redirect hop opens its own connection, so the dial-time check covers +// the whole chain: a first hop that passes cannot forward the client on to a +// blocked destination. Resolution is stubbed because every test server listens +// on the loopback interface, which no public-only client would reach at all. +func TestPublicOnlyDialContextValidatesEveryRedirectHop(t *testing.T) { + var targetRequests int target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + targetRequests++ w.WriteHeader(http.StatusOK) })) defer target.Close() - var redirectHops int redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - redirectHops++ http.Redirect(w, r, target.URL, http.StatusFound) })) defer redirector.Close() - resp, err := NewHTTPClient(HTTPClientOptions{}).Get(redirector.URL) - require.NoError(t, err) - defer resp.Body.Close() + // The URL the caller supplies resolves to a public address; the redirect + // target it is then sent to resolves to a private one. + var resolved int + resolve := func(_ context.Context, _ string) ([]net.IPAddr, error) { + resolved++ + if resolved == 1 { + return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil + } + return []net.IPAddr{{IP: net.ParseIP("10.0.0.1")}}, nil + } + + // The guard dials the address it validated, so point the dialer back at + // the test server on that same port to keep the test off the network. + dialer := &net.Dialer{} + dial := func(ctx context.Context, network, addr string) (net.Conn, error) { + _, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + return dialer.DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port)) + } + + client := &http.Client{Transport: &http.Transport{DialContext: publicOnlyDialContext(resolve, dial)}} - require.Equal(t, 1, redirectHops) - assert.Equal(t, http.StatusOK, resp.StatusCode) + _, err := client.Get(redirector.URL) + require.ErrorIs(t, err, ErrBlockedTarget) + assert.Equal(t, 2, resolved, "every hop must be resolved and validated") + assert.Zero(t, targetRequests, "the redirect target must not be reached") }