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 e265b8016..2196e4a81 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 @@ -279,7 +341,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) } @@ -291,7 +353,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 { @@ -304,7 +366,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 { @@ -335,7 +397,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) } @@ -347,7 +409,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 { @@ -360,7 +422,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 { @@ -396,7 +458,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) } @@ -408,7 +470,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 { @@ -421,7 +483,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 { @@ -455,7 +517,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) } @@ -467,7 +529,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 { @@ -480,7 +542,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 { @@ -524,7 +586,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) } @@ -536,7 +598,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 { @@ -549,7 +611,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 { @@ -582,7 +644,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) } @@ -594,7 +656,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 { @@ -607,7 +669,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 { @@ -632,7 +694,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) } @@ -644,7 +706,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 { @@ -657,7 +719,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 { @@ -709,7 +771,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) } @@ -721,7 +783,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 { @@ -734,7 +796,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 { @@ -775,7 +837,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) } @@ -787,7 +849,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 { @@ -800,7 +862,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 { @@ -862,7 +924,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) } @@ -874,7 +936,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 { @@ -887,7 +949,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 { @@ -907,7 +969,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) } @@ -919,7 +981,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 { @@ -969,7 +1031,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) } @@ -981,7 +1043,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 { @@ -1042,7 +1104,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) } @@ -1054,7 +1116,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 { @@ -1121,7 +1183,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) } @@ -1133,7 +1195,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 { @@ -1180,7 +1242,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) } @@ -1192,7 +1254,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 { @@ -1250,7 +1312,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) } @@ -1262,7 +1324,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 { @@ -1275,7 +1337,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 { @@ -1317,7 +1379,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) } @@ -1329,7 +1391,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 { @@ -1342,7 +1404,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 { @@ -1374,7 +1436,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) } @@ -1386,7 +1448,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 { @@ -1399,7 +1461,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 { @@ -1441,7 +1503,7 @@ type Data_Database struct { state protoimpl.MessageState `protogen:"open.v1"` Driver string `protobuf:"bytes,1,opt,name=driver,proto3" json:"driver,omitempty"` Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` - // default 0 + // default 0 MinOpenConns int32 `protobuf:"varint,3,opt,name=min_open_conns,json=minOpenConns,proto3" json:"min_open_conns,omitempty"` // default max(4, runtime.NumCPU()) MaxOpenConns int32 `protobuf:"varint,4,opt,name=max_open_conns,json=maxOpenConns,proto3" json:"max_open_conns,omitempty"` @@ -1453,7 +1515,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) } @@ -1465,7 +1527,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 { @@ -1478,7 +1540,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 { @@ -1530,7 +1592,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) } @@ -1542,7 +1604,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 { @@ -1555,7 +1617,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 { @@ -1597,7 +1659,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) } @@ -1609,7 +1671,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 { @@ -1622,7 +1684,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 { @@ -1663,7 +1725,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) } @@ -1675,7 +1737,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 { @@ -1688,7 +1750,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 { @@ -1744,7 +1806,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" + @@ -1771,7 +1833,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" + @@ -1794,7 +1857,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\"\x84\x01\n" + + "\x0eauthenticationJ\x04\b\b\x10\tR\x15referrer_shared_index\"J\n" + + "\x14PluginsNetworkPolicy\x122\n" + + "\x15block_private_targets\x18\x01 \x01(\bR\x13blockPrivateTargets\"\x84\x01\n" + "\fAttestations\x12&\n" + "\x0fskip_db_storage\x18\x01 \x01(\bR\rskipDbStorage\x12L\n" + "#policy_evaluations_max_inline_bytes\x18\x02 \x01(\x03R\x1fpolicyEvaluationsMaxInlineBytes\"v\n" + @@ -1891,73 +1956,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() } @@ -1965,21 +2032,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 4665594f7..4cc1aeb94 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..29ef48a8c --- /dev/null +++ b/app/controlplane/plugins/sdk/v1/httpclient.go @@ -0,0 +1,208 @@ +// +// 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. + // + // 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. + 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} + + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + 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) + +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("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. +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..f4f0498c0 --- /dev/null +++ b/app/controlplane/plugins/sdk/v1/httpclient_test.go @@ -0,0 +1,290 @@ +// +// 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}, + {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. + {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) + }) + } +} + +// 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() + + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirector.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)}} + + _, 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") +} diff --git a/deployment/chainloop/Chart.yaml b/deployment/chainloop/Chart.yaml index 24996861f..6445ba111 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.436.0 +version: 1.436.1 # Do not update appVersion, this is handled automatically by the release process appVersion: v1.108.5 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 167aed9cb..4d0399d27 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 |