diff --git a/controls/C-0266-exposuretointernet-gateway.json b/controls/C-0266-exposuretointernet-gateway.json index c349b0314..22104f647 100644 --- a/controls/C-0266-exposuretointernet-gateway.json +++ b/controls/C-0266-exposuretointernet-gateway.json @@ -1,5 +1,5 @@ { - "name": "Exposure to internet via Gateway API", + "name": "Exposure to internet via Gateway API or Istio Ingress", "attributes": { "controlTypeTags": [ "security" @@ -31,10 +31,10 @@ } ] }, - "description": "This control detect workloads that are exposed on Internet through a Gateway API (HTTPRoute,TCPRoute, UDPRoute). It fails in case it find workloads connected with these resources.", + "description": "This control detect workloads that are exposed on Internet through a Gateway API (HTTPRoute,TCPRoute, UDPRoute) or Istio Gateway. It fails in case it find workloads connected with these resources.", "remediation": "The user can evaluate its exposed resources and apply relevant changes wherever needed.", - "rulesNames": ["exposure-to-internet-via-gateway-api"], - "test": "Checks if workloads are exposed through the use of Gateway API (HTTPRoute,TCPRoute, UDPRoute).", + "rulesNames": ["exposure-to-internet-via-gateway-api","exposure-to-internet-via-istio-ingress"], + "test": "Checks if workloads are exposed through the use of Gateway API (HTTPRoute,TCPRoute, UDPRoute) or Istio Gateway.", "controlID": "C-0266", "baseScore": 7.0, "scanningScope": { diff --git a/rules/.regal/config.yaml b/rules/.regal/config.yaml index 51a89fd35..51eef3227 100644 --- a/rules/.regal/config.yaml +++ b/rules/.regal/config.yaml @@ -26,7 +26,7 @@ rules: level: ignore rule-length: level: error - max-rule-length: 50 + max-rule-length: 100 todo-comment: level: ignore use-assignment-operator: diff --git a/rules/exposure-to-internet-via-istio-ingress/raw.rego b/rules/exposure-to-internet-via-istio-ingress/raw.rego new file mode 100644 index 000000000..9d010ae64 --- /dev/null +++ b/rules/exposure-to-internet-via-istio-ingress/raw.rego @@ -0,0 +1,144 @@ +package armo_builtins +import future.keywords.in + + +deny[msga] { + virtualservice := input[_] + virtualservice.kind == "VirtualService" + + # Check if the VirtualService is connected to a Gateway + gateway := input[_] + gateway.kind == "Gateway" + + is_same_namespace(gateway, virtualservice) + virtualservice.spec.gateways[_] == gateway.metadata.name + + # Find the connected Istio Ingress Gateway that should be a LoadBalancer if it is exposed to the internet + istioingressgateway := input[_] + istioingressgateway.kind == "Service" + istioingressgateway.metadata.namespace == "istio-system" + gateway.spec.selector[_] == istioingressgateway.metadata.labels[_] + + + # Check if the Istio Ingress Gateway is exposed to the internet + is_exposed_service(istioingressgateway) + + # Check if the VirtualService is connected to an workload + # First, find the service that the VirtualService is connected to + connected_service := input[_] + connected_service.kind == "Service" + fqsn := get_fqsn(get_namespace(virtualservice), virtualservice.spec.http[_].route[_].destination.host) + target_ns := split(fqsn,".")[1] + target_name := split(fqsn,".")[0] + # Check if the service is in the same namespace as the VirtualService + get_namespace(connected_service) == target_ns + # Check if the service is the target of the VirtualService + connected_service.metadata.name == target_name + + # Check if the service is connected to a workload + wl := input[_] + is_same_namespace(connected_service, wl) + spec_template_spec_patterns := {"Deployment", "ReplicaSet", "DaemonSet", "StatefulSet", "Pod", "Job", "CronJob"} + spec_template_spec_patterns[wl.kind] + wl_connected_to_service(wl, connected_service) + + result := svc_connected_to_virtualservice(connected_service, virtualservice) + + msga := { + "alertMessage": sprintf("workload '%v' is exposed through virtualservice '%v'", [wl.metadata.name, virtualservice.metadata.name]), + "packagename": "armo_builtins", + "failedPaths": [], + "fixPaths": [], + "alertScore": 7, + "alertObject": { + "k8sApiObjects": [wl] + }, + "relatedObjects": [ + { + "object": virtualservice, + "reviewPaths": result, + "failedPaths": result, + }, + { + "object": connected_service, + } + ] + } +} + +# ==================================================================================== + +get_namespace(obj) = namespace { + obj.metadata + obj.metadata.namespace + namespace := obj.metadata.namespace +} + +get_namespace(obj) = namespace { + not obj.metadata.namespace + namespace := "default" +} + +is_same_namespace(obj1, obj2) { + obj1.metadata.namespace == obj2.metadata.namespace +} + +is_same_namespace(obj1, obj2) { + not obj1.metadata.namespace + obj2.metadata.namespace == "default" +} + +is_same_namespace(obj1, obj2) { + not obj2.metadata.namespace + obj1.metadata.namespace == "default" +} + +is_same_namespace(obj1, obj2) { + not obj1.metadata.namespace + not obj2.metadata.namespace +} + +is_exposed_service(svc) { + svc.spec.type == "NodePort" +} + +is_exposed_service(svc) { + svc.spec.type == "LoadBalancer" +} + +wl_connected_to_service(wl, svc) { + count({x | svc.spec.selector[x] == wl.metadata.labels[x]}) == count(svc.spec.selector) +} + +wl_connected_to_service(wl, svc) { + wl.spec.selector.matchLabels == svc.spec.selector +} + +wl_connected_to_service(wl, svc) { + count({x | svc.spec.selector[x] == wl.spec.template.metadata.labels[x]}) == count(svc.spec.selector) +} + +svc_connected_to_virtualservice(svc, virtualservice) = result { + host := virtualservice.spec.http[i].route[j].destination.host + svc.metadata.name == host + result := [sprintf("spec.http[%d].routes[%d].destination.host", [i,j])] +} + +get_fqsn(ns, dest_host) = fqsn { + # verify that this name is without the namespace + count(split(".", dest_host)) == 1 + fqsn := sprintf("%v.%v.svc.cluster.local", [dest_host, ns]) +} + +get_fqsn(ns, dest_host) = fqsn { + count(split(".", dest_host)) == 2 + fqsn := sprintf("%v.svc.cluster.local", [dest_host]) +} + +get_fqsn(ns, dest_host) = fqsn { + count(split(".", dest_host)) == 4 + fqsn := dest_host +} + + + diff --git a/rules/exposure-to-internet-via-istio-ingress/rule.metadata.json b/rules/exposure-to-internet-via-istio-ingress/rule.metadata.json new file mode 100644 index 000000000..cb8d1c6d3 --- /dev/null +++ b/rules/exposure-to-internet-via-istio-ingress/rule.metadata.json @@ -0,0 +1,62 @@ +{ + "name": "exposure-to-internet-via-istio-ingress", + "attributes": { + "useFromKubescapeVersion": "v3.0.9" + }, + "ruleLanguage": "Rego", + "match": [ + { + "apiGroups": [ + "" + ], + "apiVersions": [ + "v1" + ], + "resources": [ + "Pod", + "Service" + ] + }, + { + "apiGroups": [ + "apps" + ], + "apiVersions": [ + "v1" + ], + "resources": [ + "Deployment", + "ReplicaSet", + "DaemonSet", + "StatefulSet" + ] + }, + { + "apiGroups": [ + "batch" + ], + "apiVersions": [ + "*" + ], + "resources": [ + "Job", + "CronJob" + ] + }, + { + "apiGroups": [ + "networking.istio.io" + ], + "apiVersions": [ + "v1" + ], + "resources": [ + "VirtualService", + "Gateways" + ] + } + ], + "description": "fails if the running workload is bound to a Service that is exposed to the Internet through Istio Gateway.", + "remediation": "", + "ruleQuery": "armo_builtins" +} diff --git a/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/expected.json b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/expected.json new file mode 100644 index 000000000..341cbb161 --- /dev/null +++ b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/expected.json @@ -0,0 +1,92 @@ +[ + { + "alertMessage": "workload 'nginx' is exposed through virtualservice 'nginx'", + "failedPaths": [], + "reviewPaths": null, + "deletePaths": null, + "fixPaths": [], + "ruleStatus": "", + "packagename": "armo_builtins", + "alertScore": 7, + "alertObject": { + "k8sApiObjects": [ + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "nginx" + }, + "name": "nginx" + } + } + ] + }, + "relatedObjects": [ + { + "object": { + "apiVersion": "networking.istio.io/v1alpha3", + "kind": "VirtualService", + "metadata": { + "name": "nginx" + }, + "spec": { + "gateways": [ + "nginx-gateway" + ], + "hosts": [ + "*" + ], + "http": [ + { + "route": [ + { + "destination": { + "host": "nginx", + "port": { + "number": 80 + } + } + } + ] + } + ] + } + }, + "failedPaths": [ + "spec.http[0].routes[0].destination.host" + ], + "reviewPaths": [ + "spec.http[0].routes[0].destination.host" + ], + "deletePaths": null, + "fixPaths": null + }, + { + "object": { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "nginx" + }, + "spec": { + "ports": [ + { + "port": 80, + "protocol": "TCP", + "targetPort": 80 + } + ], + "selector": { + "app": "nginx" + } + } + }, + "failedPaths": null, + "reviewPaths": null, + "deletePaths": null, + "fixPaths": null + } + ] + } +] \ No newline at end of file diff --git a/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/deployment.yaml b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/deployment.yaml new file mode 100644 index 000000000..c1bad4c09 --- /dev/null +++ b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2024-11-19T19:33:37Z" + generation: 1 + labels: + app: nginx + name: nginx + namespace: default + resourceVersion: "826" + uid: 84c22298-82c1-4ca1-bc23-aeb210beffd7 +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: nginx + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + imagePullPolicy: Always + name: nginx + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 +status: + availableReplicas: 1 + conditions: + - lastTransitionTime: "2024-11-19T19:33:45Z" + lastUpdateTime: "2024-11-19T19:33:45Z" + message: Deployment has minimum availability. + reason: MinimumReplicasAvailable + status: "True" + type: Available + - lastTransitionTime: "2024-11-19T19:33:37Z" + lastUpdateTime: "2024-11-19T19:33:45Z" + message: ReplicaSet "nginx-7854ff8877" has successfully progressed. + reason: NewReplicaSetAvailable + status: "True" + type: Progressing + observedGeneration: 1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 diff --git a/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/gw.yaml b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/gw.yaml new file mode 100644 index 000000000..bd1cccd88 --- /dev/null +++ b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/gw.yaml @@ -0,0 +1,14 @@ +apiVersion: networking.istio.io/v1alpha3 +kind: Gateway +metadata: + name: nginx-gateway +spec: + selector: + istio: ingressgateway + servers: + - port: + number: 80 + name: http + protocol: HTTP + hosts: + - "*" # Accepts traffic from any host diff --git a/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/istio-gw.yaml b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/istio-gw.yaml new file mode 100644 index 000000000..94df5c99c --- /dev/null +++ b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/istio-gw.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: "2024-11-19T19:33:08Z" + labels: + app: istio-ingressgateway + app.kubernetes.io/instance: istio + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: istio-ingressgateway + app.kubernetes.io/part-of: istio + app.kubernetes.io/version: 1.24.0 + helm.sh/chart: istio-ingress-1.24.0 + install.operator.istio.io/owning-resource: unknown + install.operator.istio.io/owning-resource-namespace: istio-system + istio: ingressgateway + istio.io/rev: default + operator.istio.io/component: IngressGateways + operator.istio.io/managed: Reconcile + operator.istio.io/version: 1.24.0 + release: istio + name: istio-ingressgateway + namespace: istio-system + resourceVersion: "687" + uid: ff2d4e0e-0f4f-49cd-b96b-a709815d1e4d +spec: + allocateLoadBalancerNodePorts: true + clusterIP: 10.96.22.75 + clusterIPs: + - 10.96.22.75 + externalTrafficPolicy: Cluster + internalTrafficPolicy: Cluster + ipFamilies: + - IPv4 + ipFamilyPolicy: SingleStack + ports: + - name: status-port + nodePort: 32557 + port: 15021 + protocol: TCP + targetPort: 15021 + - name: http2 + nodePort: 31714 + port: 80 + protocol: TCP + targetPort: 8080 + - name: https + nodePort: 31885 + port: 443 + protocol: TCP + targetPort: 8443 + selector: + app: istio-ingressgateway + istio: ingressgateway + sessionAffinity: None + type: LoadBalancer +status: + loadBalancer: {} diff --git a/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/service.yaml b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/service.yaml new file mode 100644 index 000000000..1a86d579d --- /dev/null +++ b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: nginx +spec: + selector: + app: nginx # Should match your deployment's labels + ports: + - protocol: TCP + port: 80 + targetPort: 80 diff --git a/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/vs.yaml b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/vs.yaml new file mode 100644 index 000000000..ff41d0093 --- /dev/null +++ b/rules/exposure-to-internet-via-istio-ingress/test/failed_with_istiogw/input/vs.yaml @@ -0,0 +1,15 @@ +apiVersion: networking.istio.io/v1alpha3 +kind: VirtualService +metadata: + name: nginx +spec: + hosts: + - "*" + gateways: + - nginx-gateway + http: + - route: + - destination: + host: nginx # This should match your service name + port: + number: 80