diff --git a/go.mod b/go.mod index 8a0a37cb..4fbfc9e8 100644 --- a/go.mod +++ b/go.mod @@ -172,6 +172,8 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis/v4 v4.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicefabric/armservicefabric v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicefabricmanagedclusters/armservicefabricmanagedclusters v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/trafficmanager/armtrafficmanager v1.3.0 diff --git a/go.sum b/go.sum index eb9e170b..417e5307 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,10 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1/go.mod h1:8h8yhzh9o+0HeSIhUxYny+rEQajScrfIpNktvgYG3Q8= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicefabric/armservicefabric v1.2.0 h1:3N7h+QCg+UPHkm5UjMPyD8yiDofLk4X+8idyyV27R4U= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicefabric/armservicefabric v1.2.0/go.mod h1:Msj1PiUuCxDqPEW23SJUtju8dLNTzuD3nJZA7VmJoKM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicefabricmanagedclusters/armservicefabricmanagedclusters v1.0.0 h1:xM0rIelm0xigMO/SUYqaXGWV3IJknzhemunL913bpn8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicefabricmanagedclusters/armservicefabricmanagedclusters v1.0.0/go.mod h1:+taqpiJmp4uF9NaGm7wWvuips6hYNXj3d690H51VAxo= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 h1:S087deZ0kP1RUg4pU7w9U9xpUedTCbOtz+mnd0+hrkQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0/go.mod h1:B4cEyXrWBmbfMDAPnpJ1di7MAt5DKP57jPEObAvZChg= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= diff --git a/pkg/providers/azure/azure.go b/pkg/providers/azure/azure.go index 5616c410..d6bd1e94 100644 --- a/pkg/providers/azure/azure.go +++ b/pkg/providers/azure/azure.go @@ -21,7 +21,7 @@ const ( providerName = "azure" ) -var Services = []string{"vm", "publicip", "trafficmanager", "cdn", "dns", "loadbalancer", "applicationgateway", "aks", "storage", "containerinstances", "appservice", "functions", "apimanagement", "frontdoor", "containerapps", "staticwebapps", "redis", "mysql", "postgresql", "sql", "cosmosdb"} +var Services = []string{"vm", "publicip", "trafficmanager", "cdn", "dns", "loadbalancer", "applicationgateway", "aks", "storage", "containerinstances", "appservice", "functions", "apimanagement", "frontdoor", "containerapps", "staticwebapps", "redis", "mysql", "postgresql", "sql", "cosmosdb", "servicefabric"} // Provider is a data provider for Azure API using Track 2 SDK type Provider struct { @@ -286,6 +286,16 @@ func (p *Provider) Resources(ctx context.Context) (*schema.Resources, error) { resources.Merge(databases) } } + + if p.services.Has("servicefabric") { + sfp := &serviceFabricProvider{Credential: p.Credential, SubscriptionID: subscriptionID, id: p.id, extendedMetadata: p.extendedMetadata} + clusters, err := sfp.GetResource(ctx) + if err != nil { + gologger.Warning().Msgf("Error listing Service Fabric clusters for subscription %s: %s", subscriptionID, err) + continue + } + resources.Merge(clusters) + } } return resources, nil } diff --git a/pkg/providers/azure/servicefabric.go b/pkg/providers/azure/servicefabric.go new file mode 100644 index 00000000..5afc623b --- /dev/null +++ b/pkg/providers/azure/servicefabric.go @@ -0,0 +1,198 @@ +package azure + +import ( + "context" + "fmt" + "net/url" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicefabric/armservicefabric" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicefabricmanagedclusters/armservicefabricmanagedclusters" + "github.com/projectdiscovery/cloudlist/pkg/schema" + "github.com/projectdiscovery/gologger" +) + +// serviceFabricProvider is a provider for classic and managed Azure Service Fabric clusters +type serviceFabricProvider struct { + id string + SubscriptionID string + Credential azcore.TokenCredential + extendedMetadata bool + clientOptions *arm.ClientOptions +} + +// name returns the name of the provider +func (sp *serviceFabricProvider) name() string { + return "servicefabric" +} + +// GetResource returns the endpoints of classic and managed Service Fabric clusters +func (sp *serviceFabricProvider) GetResource(ctx context.Context) (*schema.Resources, error) { + list := schema.NewResources() + + // The two cluster kinds live in separate resource providers, so one being + // unregistered or forbidden must not hide the other. + classic, classicErr := sp.fetchClusters(ctx) + for _, cluster := range classic { + if cluster.Properties == nil || cluster.Properties.ManagementEndpoint == nil { + continue + } + endpoint, err := url.Parse(*cluster.Properties.ManagementEndpoint) + if err != nil || endpoint.Hostname() == "" { + continue + } + + var metadata map[string]string + if sp.extendedMetadata { + metadata = sp.getClusterMetadata(cluster) + } + + list.Append(&schema.Resource{ + Provider: providerName, + ID: sp.id, + DNSName: endpoint.Hostname(), + Service: sp.name(), + Metadata: metadata, + }) + } + + managed, managedErr := sp.fetchManagedClusters(ctx) + for _, cluster := range managed { + if cluster.Properties == nil { + continue + } + props := cluster.Properties + + var metadata map[string]string + if sp.extendedMetadata { + metadata = sp.getManagedClusterMetadata(cluster) + } + + list.Append(&schema.Resource{ + Provider: providerName, + ID: sp.id, + DNSName: stringValue(props.Fqdn), + PublicIPv4: stringValue(props.IPv4Address), + PublicIPv6: stringValue(props.IPv6Address), + Service: sp.name(), + Metadata: metadata, + }) + } + + if len(list.Items) == 0 && classicErr != nil && managedErr != nil { + return nil, fmt.Errorf("%w; %w", classicErr, managedErr) + } + for _, err := range []error{classicErr, managedErr} { + if err != nil { + gologger.Warning().Msgf("Error listing Service Fabric clusters for subscription %s: %s", sp.SubscriptionID, err) + } + } + return list, nil +} + +func (sp *serviceFabricProvider) fetchClusters(ctx context.Context) ([]*armservicefabric.Cluster, error) { + client, err := armservicefabric.NewClustersClient(sp.SubscriptionID, sp.Credential, sp.clientOptions) + if err != nil { + return nil, fmt.Errorf("failed to create Service Fabric clusters client: %w", err) + } + + // The classic API returns every cluster in the subscription in one response. + resp, err := client.List(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to list Service Fabric clusters: %w", err) + } + return resp.Value, nil +} + +func (sp *serviceFabricProvider) fetchManagedClusters(ctx context.Context) ([]*armservicefabricmanagedclusters.ManagedCluster, error) { + client, err := armservicefabricmanagedclusters.NewManagedClustersClient(sp.SubscriptionID, sp.Credential, sp.clientOptions) + if err != nil { + return nil, fmt.Errorf("failed to create Service Fabric managed clusters client: %w", err) + } + + var clusters []*armservicefabricmanagedclusters.ManagedCluster + pager := client.NewListBySubscriptionPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + // A later page can fail after earlier clusters were collected. + return clusters, fmt.Errorf("failed to list Service Fabric managed clusters: %w", err) + } + clusters = append(clusters, page.Value...) + } + return clusters, nil +} + +func (sp *serviceFabricProvider) getClusterMetadata(cluster *armservicefabric.Cluster) map[string]string { + metadata := sp.baseMetadata(cluster.Name, cluster.ID, cluster.Location, cluster.Type, cluster.Tags) + metadata["cluster_kind"] = "classic" + + props := cluster.Properties + schema.AddMetadata(metadata, "management_endpoint", props.ManagementEndpoint) + schema.AddMetadata(metadata, "cluster_code_version", props.ClusterCodeVersion) + schema.AddMetadata(metadata, "vm_image", props.VMImage) + if props.ClusterState != nil { + metadata["cluster_state"] = string(*props.ClusterState) + } + if props.ProvisioningState != nil { + metadata["provisioning_state"] = string(*props.ProvisioningState) + } + if props.ReliabilityLevel != nil { + metadata["reliability_level"] = string(*props.ReliabilityLevel) + } + return metadata +} + +func (sp *serviceFabricProvider) getManagedClusterMetadata(cluster *armservicefabricmanagedclusters.ManagedCluster) map[string]string { + metadata := sp.baseMetadata(cluster.Name, cluster.ID, cluster.Location, cluster.Type, cluster.Tags) + metadata["cluster_kind"] = "managed" + + props := cluster.Properties + schema.AddMetadata(metadata, "dns_name", props.DNSName) + schema.AddMetadata(metadata, "cluster_code_version", props.ClusterCodeVersion) + if props.ClientConnectionPort != nil { + metadata["client_connection_port"] = fmt.Sprintf("%d", *props.ClientConnectionPort) + } + if props.HTTPGatewayConnectionPort != nil { + metadata["http_gateway_connection_port"] = fmt.Sprintf("%d", *props.HTTPGatewayConnectionPort) + } + if props.ClusterState != nil { + metadata["cluster_state"] = string(*props.ClusterState) + } + if props.ProvisioningState != nil { + metadata["provisioning_state"] = string(*props.ProvisioningState) + } + if cluster.SKU != nil && cluster.SKU.Name != nil { + metadata["sku"] = string(*cluster.SKU.Name) + } + return metadata +} + +func (sp *serviceFabricProvider) baseMetadata(name, id, location, resourceType *string, tags map[string]*string) map[string]string { + metadata := make(map[string]string) + + schema.AddMetadata(metadata, "cluster_name", name) + schema.AddMetadata(metadata, "cluster_id", id) + metadata["subscription_id"] = sp.SubscriptionID + metadata["owner_id"] = sp.SubscriptionID + schema.AddMetadata(metadata, "location", location) + schema.AddMetadata(metadata, "type", resourceType) + + if id != nil { + if _, resourceGroup := parseAzureResourceID(*id); resourceGroup != "" { + metadata["resource_group"] = resourceGroup + } + } + if tagString := buildAzureTagString(tags); tagString != "" { + metadata["tags"] = tagString + } + return metadata +} + +func stringValue(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/pkg/providers/azure/servicefabric_test.go b/pkg/providers/azure/servicefabric_test.go new file mode 100644 index 00000000..a8f3d853 --- /dev/null +++ b/pkg/providers/azure/servicefabric_test.go @@ -0,0 +1,186 @@ +package azure + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/projectdiscovery/cloudlist/pkg/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const classicClustersResponse = `{"value": [ + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.ServiceFabric/clusters/classic1", + "name": "classic1", + "type": "Microsoft.ServiceFabric/clusters", + "location": "eastus", + "tags": {"env": "prod"}, + "properties": { + "managementEndpoint": "https://classic1.eastus.cloudapp.azure.com:19080", + "clusterEndpoint": "https://eastus.servicefabric.azure.com/runtime/clusters/abc", + "clusterState": "Ready", + "reliabilityLevel": "Silver", + "nodeTypes": [] + } + }, + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.ServiceFabric/clusters/classic2", + "name": "classic2", + "location": "westus", + "properties": {"managementEndpoint": "https://20.42.0.10:19080", "nodeTypes": []} + }, + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.ServiceFabric/clusters/no-endpoint", + "name": "no-endpoint", + "location": "westus", + "properties": {"nodeTypes": []} + } +]}` + +const managedClustersPage1 = `{"value": [ + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg2/providers/Microsoft.ServiceFabric/managedClusters/managed1", + "name": "managed1", + "location": "eastus", + "sku": {"name": "Standard"}, + "properties": { + "dnsName": "managed1", + "adminUserName": "admin", + "fqdn": "managed1.eastus.cloudapp.azure.com", + "ipv4Address": "20.42.0.20", + "ipv6Address": "2603:1030:20e:3::23c", + "clientConnectionPort": 19000, + "httpGatewayConnectionPort": 19080, + "clusterState": "Ready" + } + } +], "nextLink": "%s/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.ServiceFabric/managedClusters?page=2"}` + +const managedClustersPage2 = `{"value": [ + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg2/providers/Microsoft.ServiceFabric/managedClusters/managed2", + "name": "managed2", + "location": "westus", + "sku": {"name": "Basic"}, + "properties": {"dnsName": "managed2", "adminUserName": "admin", "fqdn": "managed2.westus.cloudapp.azure.com"} + } +]}` + +// newTestServiceFabricProvider points the real SDK clients at a fake ARM endpoint. +func newTestServiceFabricProvider(t *testing.T, classicStatus, page2Status int) *serviceFabricProvider { + t.Helper() + var server *httptest.Server + server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/providers/Microsoft.ServiceFabric/clusters"): + if classicStatus != http.StatusOK { + w.WriteHeader(classicStatus) + _, _ = w.Write([]byte(`{"error": {"code": "AuthorizationFailed", "message": "denied"}}`)) + return + } + _, _ = w.Write([]byte(classicClustersResponse)) + case strings.HasSuffix(r.URL.Path, "/providers/Microsoft.ServiceFabric/managedClusters"): + if r.URL.Query().Get("page") == "2" { + if page2Status != http.StatusOK { + w.WriteHeader(page2Status) + _, _ = w.Write([]byte(`{"error": {"code": "InternalError", "message": "page failed"}}`)) + return + } + _, _ = w.Write([]byte(managedClustersPage2)) + return + } + _, _ = fmt.Fprintf(w, managedClustersPage1, server.URL) + default: + t.Errorf("unexpected request: %s", r.URL) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + return &serviceFabricProvider{ + id: "test", + SubscriptionID: testSubscriptionID, + Credential: fakeCredential{}, + extendedMetadata: true, + clientOptions: &arm.ClientOptions{ + ClientOptions: policy.ClientOptions{ + Cloud: cloud.Configuration{ + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Endpoint: server.URL, Audience: server.URL}, + }, + }, + Transport: server.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + }, + }, + } +} + +func resourceByValue(resources *schema.Resources) map[string]*schema.Resource { + byValue := make(map[string]*schema.Resource) + for _, r := range resources.Items { + for _, v := range []string{r.DNSName, r.PublicIPv4, r.PublicIPv6} { + if v != "" { + byValue[v] = r + } + } + } + return byValue +} + +func TestServiceFabricGetResource(t *testing.T) { + resources, err := newTestServiceFabricProvider(t, http.StatusOK, http.StatusOK).GetResource(context.Background()) + require.NoError(t, err) + + byValue := resourceByValue(resources) + require.Len(t, byValue, 6, "classic endpoints, managed fqdn/ipv4/ipv6 across both pages") + + classic := byValue["classic1.eastus.cloudapp.azure.com"] + require.NotNil(t, classic, "classic management endpoint host must be extracted without scheme or port") + assert.Equal(t, "servicefabric", classic.Service) + assert.Equal(t, "classic", classic.Metadata["cluster_kind"]) + assert.Equal(t, "rg1", classic.Metadata["resource_group"]) + assert.Equal(t, "Silver", classic.Metadata["reliability_level"]) + assert.Equal(t, "env=prod", classic.Metadata["tags"]) + + assert.NotNil(t, byValue["20.42.0.10"], "classic endpoint addressed by IP must be kept") + assert.Nil(t, byValue["eastus.servicefabric.azure.com"], "the Azure-owned cluster endpoint is not an asset") + + managedIPv4 := byValue["20.42.0.20"] + require.NotNil(t, managedIPv4) + assert.True(t, managedIPv4.Public) + assert.Equal(t, "managed", managedIPv4.Metadata["cluster_kind"]) + assert.Equal(t, "19080", managedIPv4.Metadata["http_gateway_connection_port"]) + assert.Equal(t, "Standard", managedIPv4.Metadata["sku"]) + assert.NotNil(t, byValue["managed1.eastus.cloudapp.azure.com"]) + assert.NotNil(t, byValue["2603:1030:20e:3::23c"]) + assert.NotNil(t, byValue["managed2.westus.cloudapp.azure.com"], "second page must be fetched") +} + +func TestServiceFabricGetResourceClassicForbidden(t *testing.T) { + resources, err := newTestServiceFabricProvider(t, http.StatusForbidden, http.StatusOK).GetResource(context.Background()) + require.NoError(t, err, "a failing classic API must not hide managed clusters") + + byValue := resourceByValue(resources) + assert.NotNil(t, byValue["managed1.eastus.cloudapp.azure.com"]) + assert.Nil(t, byValue["classic1.eastus.cloudapp.azure.com"]) +} + +func TestServiceFabricKeepsEarlierManagedPage(t *testing.T) { + resources, err := newTestServiceFabricProvider(t, http.StatusOK, http.StatusInternalServerError).GetResource(context.Background()) + require.NoError(t, err) + + byValue := resourceByValue(resources) + assert.NotNil(t, byValue["managed1.eastus.cloudapp.azure.com"]) + assert.NotNil(t, byValue["classic1.eastus.cloudapp.azure.com"]) + assert.Nil(t, byValue["managed2.westus.cloudapp.azure.com"]) +}