From c39670a5c9cbae5f7879982bc41502341c74c1fc Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Tue, 6 Oct 2026 00:26:57 +1100 Subject: [PATCH 1/5] fix(iac): grant EC2 instance profile inventory reads --- .../cloudformation/nullify-cloudformation-template.json | 1 + .../terraform-v6/modules/nullify-aws-integration/data.tf | 1 + .../terraform/modules/nullify-aws-integration/data.tf | 1 + 3 files changed, 3 insertions(+) diff --git a/aws-integration-setup/cloudformation/nullify-cloudformation-template.json b/aws-integration-setup/cloudformation/nullify-cloudformation-template.json index 8d241bd..95530c7 100644 --- a/aws-integration-setup/cloudformation/nullify-cloudformation-template.json +++ b/aws-integration-setup/cloudformation/nullify-cloudformation-template.json @@ -542,6 +542,7 @@ "iam:ListGroupPolicies", "iam:ListGroups", "iam:ListGroupsForUser", + "iam:ListInstanceProfiles", "iam:ListMFADevices", "iam:ListOpenIDConnectProviders", "iam:ListPolicies", diff --git a/aws-integration-setup/terraform-v6/modules/nullify-aws-integration/data.tf b/aws-integration-setup/terraform-v6/modules/nullify-aws-integration/data.tf index efc29a5..07b2bce 100644 --- a/aws-integration-setup/terraform-v6/modules/nullify-aws-integration/data.tf +++ b/aws-integration-setup/terraform-v6/modules/nullify-aws-integration/data.tf @@ -176,6 +176,7 @@ data "aws_iam_policy_document" "readonly_policy_part2" { "iam:ListGroupPolicies", "iam:ListGroups", "iam:ListGroupsForUser", + "iam:ListInstanceProfiles", "iam:ListMFADevices", "iam:ListOpenIDConnectProviders", "iam:ListPolicies", diff --git a/aws-integration-setup/terraform/modules/nullify-aws-integration/data.tf b/aws-integration-setup/terraform/modules/nullify-aws-integration/data.tf index 12ff65f..0516aca 100644 --- a/aws-integration-setup/terraform/modules/nullify-aws-integration/data.tf +++ b/aws-integration-setup/terraform/modules/nullify-aws-integration/data.tf @@ -200,6 +200,7 @@ data "aws_iam_policy_document" "readonly_policy_part2" { "iam:ListGroupPolicies", "iam:ListGroups", "iam:ListGroupsForUser", + "iam:ListInstanceProfiles", "iam:ListMFADevices", "iam:ListOpenIDConnectProviders", "iam:ListPolicies", From a25572952f081654cb0ba9fde1ed336006aaa632 Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 13 Oct 2026 15:53:06 +1100 Subject: [PATCH 2/5] feat(context): grant Kubernetes routing inventory permissions --- .github/workflows/helm-release.yml | 5 ++ .github/workflows/terraform-validate.yml | 3 + .../modules/k8s-resources/main.tf | 14 +++- .../modules/k8s-resources/managed_scan.tf | 10 ++- .../modules/k8s-resources/variables.tf | 2 +- .../terraform/modules/k8s-resources/main.tf | 14 +++- .../modules/k8s-resources/managed_scan.tf | 10 ++- .../modules/k8s-resources/variables.tf | 2 +- helm-charts/nullify-k8s-collector/Chart.yaml | 2 +- helm-charts/nullify-k8s-collector/README.md | 21 ++++- .../templates/clusterrole.yaml | 8 +- helm-charts/nullify-k8s-collector/values.yaml | 2 +- scripts/test_routing_rbac.py | 78 +++++++++++++++++++ 13 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 scripts/test_routing_rbac.py diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml index 06f2f0b..41ae70c 100644 --- a/.github/workflows/helm-release.yml +++ b/.github/workflows/helm-release.yml @@ -7,6 +7,7 @@ on: - "manifests/**" - "scripts/publish-helm-repo.sh" - "scripts/test-helm-charts.sh" + - "scripts/test_routing_rbac.py" - "tests/helm/**" - ".github/workflows/helm-release.yml" push: @@ -16,6 +17,7 @@ on: - "manifests/**" - "scripts/publish-helm-repo.sh" - "scripts/test-helm-charts.sh" + - "scripts/test_routing_rbac.py" - "tests/helm/**" - ".github/workflows/helm-release.yml" workflow_dispatch: @@ -64,6 +66,9 @@ jobs: with: version: ${{ env.HELM_VERSION }} + - name: Check routing collector RBAC + run: python3 scripts/test_routing_rbac.py + - name: Lint and render run: scripts/test-helm-charts.sh "$CHART" diff --git a/.github/workflows/terraform-validate.yml b/.github/workflows/terraform-validate.yml index 7e096a2..8ea47bd 100644 --- a/.github/workflows/terraform-validate.yml +++ b/.github/workflows/terraform-validate.yml @@ -148,6 +148,9 @@ jobs: - name: compare Allow/Deny action sets run: python3 scripts/check_iam_policy_drift.py + - name: Check routing collector RBAC + run: python3 scripts/test_routing_rbac.py + shellcheck: runs-on: ubuntu-latest steps: diff --git a/aws-integration-setup/terraform-v6/modules/k8s-resources/main.tf b/aws-integration-setup/terraform-v6/modules/k8s-resources/main.tf index a9d66f5..888175b 100644 --- a/aws-integration-setup/terraform-v6/modules/k8s-resources/main.tf +++ b/aws-integration-setup/terraform-v6/modules/k8s-resources/main.tf @@ -174,7 +174,19 @@ resource "kubernetes_cluster_role" "nullify_readonly_role" { } rule { - non_resource_urls = ["/version"] + api_groups = ["gateway.networking.k8s.io"] + resources = ["gatewayclasses", "gateways", "httproutes", "referencegrants"] + verbs = ["list"] + } + + rule { + api_groups = ["networking.istio.io"] + resources = ["virtualservices", "serviceentries", "destinationrules"] + verbs = ["list"] + } + + rule { + non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs = ["get"] } } diff --git a/aws-integration-setup/terraform-v6/modules/k8s-resources/managed_scan.tf b/aws-integration-setup/terraform-v6/modules/k8s-resources/managed_scan.tf index a047f5a..2bfb76f 100644 --- a/aws-integration-setup/terraform-v6/modules/k8s-resources/managed_scan.tf +++ b/aws-integration-setup/terraform-v6/modules/k8s-resources/managed_scan.tf @@ -31,6 +31,14 @@ locals { api_groups = ["admissionregistration.k8s.io"] resources = ["validatingwebhookconfigurations", "mutatingwebhookconfigurations", "validatingadmissionpolicies", "validatingadmissionpolicybindings"] }, + { + api_groups = ["gateway.networking.k8s.io"] + resources = ["gatewayclasses", "gateways", "httproutes", "referencegrants"] + }, + { + api_groups = ["networking.istio.io"] + resources = ["virtualservices", "serviceentries", "destinationrules"] + }, ] } @@ -56,7 +64,7 @@ resource "kubernetes_cluster_role_v1" "nullify_readonly" { } rule { - non_resource_urls = ["/version"] + non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs = ["get"] } } diff --git a/aws-integration-setup/terraform-v6/modules/k8s-resources/variables.tf b/aws-integration-setup/terraform-v6/modules/k8s-resources/variables.tf index 61d5cd9..2cd1130 100644 --- a/aws-integration-setup/terraform-v6/modules/k8s-resources/variables.tf +++ b/aws-integration-setup/terraform-v6/modules/k8s-resources/variables.tf @@ -72,7 +72,7 @@ variable "kubernetes_namespace" { variable "collector_image" { type = string description = "Container image for the Kubernetes collector. Defaults to a pinned tag of Nullify's ECR Public image" - default = "public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.46.0" + default = "public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.48.0" } variable "cronjob_schedule" { diff --git a/aws-integration-setup/terraform/modules/k8s-resources/main.tf b/aws-integration-setup/terraform/modules/k8s-resources/main.tf index a9d66f5..888175b 100644 --- a/aws-integration-setup/terraform/modules/k8s-resources/main.tf +++ b/aws-integration-setup/terraform/modules/k8s-resources/main.tf @@ -174,7 +174,19 @@ resource "kubernetes_cluster_role" "nullify_readonly_role" { } rule { - non_resource_urls = ["/version"] + api_groups = ["gateway.networking.k8s.io"] + resources = ["gatewayclasses", "gateways", "httproutes", "referencegrants"] + verbs = ["list"] + } + + rule { + api_groups = ["networking.istio.io"] + resources = ["virtualservices", "serviceentries", "destinationrules"] + verbs = ["list"] + } + + rule { + non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs = ["get"] } } diff --git a/aws-integration-setup/terraform/modules/k8s-resources/managed_scan.tf b/aws-integration-setup/terraform/modules/k8s-resources/managed_scan.tf index a047f5a..2bfb76f 100644 --- a/aws-integration-setup/terraform/modules/k8s-resources/managed_scan.tf +++ b/aws-integration-setup/terraform/modules/k8s-resources/managed_scan.tf @@ -31,6 +31,14 @@ locals { api_groups = ["admissionregistration.k8s.io"] resources = ["validatingwebhookconfigurations", "mutatingwebhookconfigurations", "validatingadmissionpolicies", "validatingadmissionpolicybindings"] }, + { + api_groups = ["gateway.networking.k8s.io"] + resources = ["gatewayclasses", "gateways", "httproutes", "referencegrants"] + }, + { + api_groups = ["networking.istio.io"] + resources = ["virtualservices", "serviceentries", "destinationrules"] + }, ] } @@ -56,7 +64,7 @@ resource "kubernetes_cluster_role_v1" "nullify_readonly" { } rule { - non_resource_urls = ["/version"] + non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs = ["get"] } } diff --git a/aws-integration-setup/terraform/modules/k8s-resources/variables.tf b/aws-integration-setup/terraform/modules/k8s-resources/variables.tf index 61d5cd9..2cd1130 100644 --- a/aws-integration-setup/terraform/modules/k8s-resources/variables.tf +++ b/aws-integration-setup/terraform/modules/k8s-resources/variables.tf @@ -72,7 +72,7 @@ variable "kubernetes_namespace" { variable "collector_image" { type = string description = "Container image for the Kubernetes collector. Defaults to a pinned tag of Nullify's ECR Public image" - default = "public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.46.0" + default = "public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.48.0" } variable "cronjob_schedule" { diff --git a/helm-charts/nullify-k8s-collector/Chart.yaml b/helm-charts/nullify-k8s-collector/Chart.yaml index 0a7ffc5..48e2ae2 100644 --- a/helm-charts/nullify-k8s-collector/Chart.yaml +++ b/helm-charts/nullify-k8s-collector/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: nullify-k8s-collector description: A Helm chart for Nullify's Kubernetes information collector for cloud security scanning type: application -version: 0.4.1 +version: 0.5.0 kubeVersion: ">=1.21.0-0" maintainers: - name: Nullify diff --git a/helm-charts/nullify-k8s-collector/README.md b/helm-charts/nullify-k8s-collector/README.md index a2af962..c3b5617 100644 --- a/helm-charts/nullify-k8s-collector/README.md +++ b/helm-charts/nullify-k8s-collector/README.md @@ -10,7 +10,7 @@ This Helm chart deploys a Kubernetes collector for the Nullify platform to gathe - **GKE:** the cluster registered with Nullify (see [GKE](#gke-with-workload-identity-federation)) - Egress from the collector pod over HTTPS to `public.ecr.aws`, and to STS and S3 in the Nullify bucket's region -> **Image tag:** the default `collector.image.tag` is `k8s-collector-3.46.0`, +> **Image tag:** the default `collector.image.tag` is `k8s-collector-3.48.0`, > the same pin as the Terraform `k8s-resources` module. ## Supported platforms @@ -59,7 +59,7 @@ The chart refuses to render when a required value is missing or still a placehol | `serviceAccount.name` | Name of the service account | `nullify-k8s-collector-sa` | | `serviceAccount.namespace` | Namespace of every namespaced object; `""` means the release namespace. Must already exist. | `nullify` | | `collector.image.repository` | Image repository | `public.ecr.aws/w4o2j2x4/integrations` | -| `collector.image.tag` | Image tag | `k8s-collector-3.46.0` | +| `collector.image.tag` | Image tag | `k8s-collector-3.48.0` | | `collector.image.pullPolicy` | Pull policy | `IfNotPresent` | | `collector.schedule` | CronJob schedule | `0 0 * * *` (daily at midnight) | | `collector.s3.bucket` | S3 bucket for storing data (from Nullify configure page) | `""` (required) | @@ -248,3 +248,20 @@ To enable debug logging for troubleshooting: ``` Debug mode enables the `ENABLE_DEBUG_LOG` environment variable, which provides more detailed logging information to help diagnose collection issues. + +## Routing inventory + +Collector 3.48.0 lists GatewayClass, Gateway, HTTPRoute and ReferenceGrant +from `gateway.networking.k8s.io`, and VirtualService, ServiceEntry and +DestinationRule from `networking.istio.io`. Only installed, supported API +versions are listed. The scan payload records complete (including empty), +absent, denied, failed and unsupported outcomes for each resource kind. + +The chart grants `list` on these seven resources and `get` on API discovery +endpoints. It does not grant Secret access or write/watch permissions. +Header/query literals, direct-response bodies and status messages are redacted; certificate and +credential references are retained. Collected declarations do not prove +that a route is programmed or receiving traffic. + +Upgrading an existing collector requires both the new image and the updated +ClusterRole. Publish collector 3.48.0 before installing this chart version. diff --git a/helm-charts/nullify-k8s-collector/templates/clusterrole.yaml b/helm-charts/nullify-k8s-collector/templates/clusterrole.yaml index ded6e10..6d80a85 100644 --- a/helm-charts/nullify-k8s-collector/templates/clusterrole.yaml +++ b/helm-charts/nullify-k8s-collector/templates/clusterrole.yaml @@ -56,6 +56,12 @@ rules: - apiGroups: ["apiextensions.k8s.io"] resources: ["customresourcedefinitions"] verbs: ["list"] - - nonResourceURLs: ["/version"] + - apiGroups: ["gateway.networking.k8s.io"] + resources: ["gatewayclasses", "gateways", "httproutes", "referencegrants"] + verbs: ["list"] + - apiGroups: ["networking.istio.io"] + resources: ["virtualservices", "serviceentries", "destinationrules"] + verbs: ["list"] + - nonResourceURLs: ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs: ["get"] {{- end }} \ No newline at end of file diff --git a/helm-charts/nullify-k8s-collector/values.yaml b/helm-charts/nullify-k8s-collector/values.yaml index e40651b..d63b75a 100644 --- a/helm-charts/nullify-k8s-collector/values.yaml +++ b/helm-charts/nullify-k8s-collector/values.yaml @@ -51,7 +51,7 @@ collector: # on k8s-collector-latest: a moved tag is what every Helm install runs. image: repository: public.ecr.aws/w4o2j2x4/integrations - tag: k8s-collector-3.46.0 + tag: k8s-collector-3.48.0 pullPolicy: IfNotPresent # Resource limits diff --git a/scripts/test_routing_rbac.py b/scripts/test_routing_rbac.py new file mode 100644 index 0000000..447a5e8 --- /dev/null +++ b/scripts/test_routing_rbac.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Check the routing collector's permissions in every maintained RBAC template.""" +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RESOURCES = { + "gateway.networking.k8s.io": { + "gatewayclasses", "gateways", "httproutes", "referencegrants" + }, + "networking.istio.io": { + "virtualservices", "serviceentries", "destinationrules" + }, +} +DISCOVERY = { + "/api", "/apis", + "/apis/gateway.networking.k8s.io/v1", + "/apis/gateway.networking.k8s.io/v1beta1", + "/apis/networking.istio.io/v1", + "/apis/networking.istio.io/v1beta1", + "/apis/networking.istio.io/v1alpha3", +} +RULE = re.compile( + r"api_?[gG]roups\s*[:=]\s*\[([^\]]*)\]\s*" + r"resources\s*[:=]\s*\[([^\]]*)\]" +) + + +def values(raw): + return set(re.findall(r'"([^\"]*)"', raw)) + + +class RoutingRBAC(unittest.TestCase): + def test_routing_permissions(self): + paths = [ + ROOT / "helm-charts/nullify-k8s-collector/templates/clusterrole.yaml" + ] + for version in ("terraform", "terraform-v6"): + base = ROOT / "aws-integration-setup" / version / "modules/k8s-resources" + paths.extend((base / "main.tf", base / "managed_scan.tf")) + for path in paths: + with self.subTest(path=path.relative_to(ROOT)): + raw = path.read_text() + found = {} + for match in RULE.finditer(raw): + for group in values(match[1]): + if group in RESOURCES: + self.assertNotIn(group, found) + found[group] = values(match[2]) + if path.name != "managed_scan.tf": + verb = re.match( + r"\s*verbs\s*[:=]\s*\[([^\]]*)\]", + raw[match.end():], + ) + self.assertIsNotNone(verb) + self.assertEqual(values(verb[1]), {"list"}) + self.assertEqual(found, RESOURCES) + if path.name == "managed_scan.tf": + self.assertRegex(raw, r'verbs\s*=\s*\["list"\]') + discovery = re.search( + r"non_?[rR]esource_?[uU][rR][lL][sS]\s*[:=]\s*\[([^\]]*)\]", + raw, + ) + self.assertIsNotNone(discovery) + endpoints = values(discovery[1]) + self.assertTrue(DISCOVERY <= endpoints) + self.assertFalse(any("*" in endpoint for endpoint in endpoints)) + verb = re.match( + r"\s*verbs\s*[:=]\s*\[([^\]]*)\]", + raw[discovery.end():], + ) + self.assertIsNotNone(verb) + self.assertEqual(values(verb[1]), {"get"}) + + +if __name__ == "__main__": + unittest.main() From 70eaf88436a56a067d777819bfed54b4ee2fa5a0 Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 13 Oct 2026 16:35:26 +1100 Subject: [PATCH 3/5] fix(context): discover experimental routing API metadata safely --- .../terraform-v6/modules/k8s-resources/main.tf | 2 +- .../terraform-v6/modules/k8s-resources/managed_scan.tf | 2 +- .../terraform/modules/k8s-resources/main.tf | 2 +- .../terraform/modules/k8s-resources/managed_scan.tf | 2 +- helm-charts/nullify-k8s-collector/README.md | 8 ++++++-- .../nullify-k8s-collector/templates/clusterrole.yaml | 2 +- scripts/test_routing_rbac.py | 2 ++ 7 files changed, 13 insertions(+), 7 deletions(-) diff --git a/aws-integration-setup/terraform-v6/modules/k8s-resources/main.tf b/aws-integration-setup/terraform-v6/modules/k8s-resources/main.tf index 888175b..9fb198e 100644 --- a/aws-integration-setup/terraform-v6/modules/k8s-resources/main.tf +++ b/aws-integration-setup/terraform-v6/modules/k8s-resources/main.tf @@ -186,7 +186,7 @@ resource "kubernetes_cluster_role" "nullify_readonly_role" { } rule { - non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] + non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/gateway.networking.k8s.io/v1alpha2", "/apis/gateway.networking.k8s.io/v1alpha3", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs = ["get"] } } diff --git a/aws-integration-setup/terraform-v6/modules/k8s-resources/managed_scan.tf b/aws-integration-setup/terraform-v6/modules/k8s-resources/managed_scan.tf index 2bfb76f..bd484aa 100644 --- a/aws-integration-setup/terraform-v6/modules/k8s-resources/managed_scan.tf +++ b/aws-integration-setup/terraform-v6/modules/k8s-resources/managed_scan.tf @@ -64,7 +64,7 @@ resource "kubernetes_cluster_role_v1" "nullify_readonly" { } rule { - non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] + non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/gateway.networking.k8s.io/v1alpha2", "/apis/gateway.networking.k8s.io/v1alpha3", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs = ["get"] } } diff --git a/aws-integration-setup/terraform/modules/k8s-resources/main.tf b/aws-integration-setup/terraform/modules/k8s-resources/main.tf index 888175b..9fb198e 100644 --- a/aws-integration-setup/terraform/modules/k8s-resources/main.tf +++ b/aws-integration-setup/terraform/modules/k8s-resources/main.tf @@ -186,7 +186,7 @@ resource "kubernetes_cluster_role" "nullify_readonly_role" { } rule { - non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] + non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/gateway.networking.k8s.io/v1alpha2", "/apis/gateway.networking.k8s.io/v1alpha3", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs = ["get"] } } diff --git a/aws-integration-setup/terraform/modules/k8s-resources/managed_scan.tf b/aws-integration-setup/terraform/modules/k8s-resources/managed_scan.tf index 2bfb76f..bd484aa 100644 --- a/aws-integration-setup/terraform/modules/k8s-resources/managed_scan.tf +++ b/aws-integration-setup/terraform/modules/k8s-resources/managed_scan.tf @@ -64,7 +64,7 @@ resource "kubernetes_cluster_role_v1" "nullify_readonly" { } rule { - non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] + non_resource_urls = ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/gateway.networking.k8s.io/v1alpha2", "/apis/gateway.networking.k8s.io/v1alpha3", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs = ["get"] } } diff --git a/helm-charts/nullify-k8s-collector/README.md b/helm-charts/nullify-k8s-collector/README.md index c3b5617..a3d624d 100644 --- a/helm-charts/nullify-k8s-collector/README.md +++ b/helm-charts/nullify-k8s-collector/README.md @@ -254,12 +254,16 @@ Debug mode enables the `ENABLE_DEBUG_LOG` environment variable, which provides m Collector 3.48.0 lists GatewayClass, Gateway, HTTPRoute and ReferenceGrant from `gateway.networking.k8s.io`, and VirtualService, ServiceEntry and DestinationRule from `networking.istio.io`. Only installed, supported API -versions are listed. The scan payload records complete (including empty), +versions are listed. Discovery inspects experimental Gateway API version +metadata to distinguish an absent kind from an unsupported version; it never +lists experimental routing objects. Routing failures stay isolated from core +cluster collection. The scan payload records complete (including empty), absent, denied, failed and unsupported outcomes for each resource kind. The chart grants `list` on these seven resources and `get` on API discovery endpoints. It does not grant Secret access or write/watch permissions. -Header/query literals, direct-response bodies and status messages are redacted; certificate and +Header/query literals (including negative Istio header matches), infrastructure +annotation/label values, direct-response bodies and status messages are redacted; certificate and credential references are retained. Collected declarations do not prove that a route is programmed or receiving traffic. diff --git a/helm-charts/nullify-k8s-collector/templates/clusterrole.yaml b/helm-charts/nullify-k8s-collector/templates/clusterrole.yaml index 6d80a85..073411d 100644 --- a/helm-charts/nullify-k8s-collector/templates/clusterrole.yaml +++ b/helm-charts/nullify-k8s-collector/templates/clusterrole.yaml @@ -62,6 +62,6 @@ rules: - apiGroups: ["networking.istio.io"] resources: ["virtualservices", "serviceentries", "destinationrules"] verbs: ["list"] - - nonResourceURLs: ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] + - nonResourceURLs: ["/version", "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", "/apis/gateway.networking.k8s.io/v1alpha2", "/apis/gateway.networking.k8s.io/v1alpha3", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3"] verbs: ["get"] {{- end }} \ No newline at end of file diff --git a/scripts/test_routing_rbac.py b/scripts/test_routing_rbac.py index 447a5e8..2a45b44 100644 --- a/scripts/test_routing_rbac.py +++ b/scripts/test_routing_rbac.py @@ -17,6 +17,8 @@ "/api", "/apis", "/apis/gateway.networking.k8s.io/v1", "/apis/gateway.networking.k8s.io/v1beta1", + "/apis/gateway.networking.k8s.io/v1alpha2", + "/apis/gateway.networking.k8s.io/v1alpha3", "/apis/networking.istio.io/v1", "/apis/networking.istio.io/v1beta1", "/apis/networking.istio.io/v1alpha3", From cb71a048f4e56032f485d9ecd614bdd8bc03e60a Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 13 Oct 2026 18:01:58 +1100 Subject: [PATCH 4/5] test(context): align collector image and routing RBAC expectations --- .../tests/k8s_resources.tftest.hcl | 38 +++++++++++++------ .../tests/k8s_resources.tftest.hcl | 38 +++++++++++++------ 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/aws-integration-setup/terraform-v6/modules/k8s-resources/tests/k8s_resources.tftest.hcl b/aws-integration-setup/terraform-v6/modules/k8s-resources/tests/k8s_resources.tftest.hcl index 39ebcda..a381136 100644 --- a/aws-integration-setup/terraform-v6/modules/k8s-resources/tests/k8s_resources.tftest.hcl +++ b/aws-integration-setup/terraform-v6/modules/k8s-resources/tests/k8s_resources.tftest.hcl @@ -26,7 +26,7 @@ run "collector_only_is_the_default" { } assert { - condition = kubernetes_cron_job_v1.k8s_collector[0].spec[0].job_template[0].spec[0].template[0].spec[0].container[0].image == "public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.46.0" + condition = kubernetes_cron_job_v1.k8s_collector[0].spec[0].job_template[0].spec[0].template[0].spec[0].container[0].image == "public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.48.0" error_message = "The collector image must default to the pinned Nullify ECR Public tag" } @@ -135,18 +135,15 @@ run "managed_scan_rbac_only" { } assert { - condition = length(kubernetes_cluster_role_v1.nullify_readonly[0].rule) == 8 - error_message = "The managed-scan ClusterRole must have seven resource rules and one /version rule" + condition = length(kubernetes_cluster_role_v1.nullify_readonly[0].rule) == 10 + error_message = "The managed-scan ClusterRole must have nine resource rules and one discovery rule" } assert { - condition = sum([for rule in slice(kubernetes_cluster_role_v1.nullify_readonly[0].rule, 0, 7) : length(rule.resources)]) == 26 - error_message = "The managed-scan ClusterRole must list exactly the 26 config kinds; secrets are omitted because list returns values" + condition = sum([for rule in slice(kubernetes_cluster_role_v1.nullify_readonly[0].rule, 0, 9) : length(rule.resources)]) == 33 + error_message = "The managed-scan ClusterRole must list exactly the 33 config and routing kinds; secrets are omitted because list returns values" } - # Pins the set, not just the size: swapping one kind for another keeps every - # count assertion green. Anything else that binds this group -- a chart, a - # manifest -- has to grant exactly these kinds. assert { condition = tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[0].api_groups) == tolist([""]) && toset(kubernetes_cluster_role_v1.nullify_readonly[0].rule[0].resources) == toset(["nodes", "namespaces", "pods", "services", "persistentvolumeclaims", "persistentvolumes", "configmaps", "resourcequotas", "limitranges", "serviceaccounts"]) error_message = "Rule 0 must grant exactly the core API group kinds the scanner lists" @@ -183,13 +180,32 @@ run "managed_scan_rbac_only" { } assert { - condition = alltrue([for rule in slice(kubernetes_cluster_role_v1.nullify_readonly[0].rule, 0, 7) : tolist(rule.verbs) == tolist(["list"])]) + condition = tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[7].api_groups) == tolist(["gateway.networking.k8s.io"]) && toset(kubernetes_cluster_role_v1.nullify_readonly[0].rule[7].resources) == toset(["gatewayclasses", "gateways", "httproutes", "referencegrants"]) + error_message = "Rule 7 must grant exactly the four Gateway API routing kinds" + } + + assert { + condition = tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[8].api_groups) == tolist(["networking.istio.io"]) && toset(kubernetes_cluster_role_v1.nullify_readonly[0].rule[8].resources) == toset(["virtualservices", "serviceentries", "destinationrules"]) + error_message = "Rule 8 must grant exactly the three Istio routing kinds" + } + + assert { + condition = alltrue([for rule in slice(kubernetes_cluster_role_v1.nullify_readonly[0].rule, 0, 9) : tolist(rule.verbs) == tolist(["list"])]) error_message = "Resource rules must grant list only" } assert { - condition = tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[7].non_resource_urls) == tolist(["/version"]) && tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[7].verbs) == tolist(["get"]) - error_message = "The only non-resource grant must be get /version" + condition = toset(kubernetes_cluster_role_v1.nullify_readonly[0].rule[9].non_resource_urls) == toset([ + "/version", "/api", "/apis", + "/apis/gateway.networking.k8s.io/v1", + "/apis/gateway.networking.k8s.io/v1beta1", + "/apis/gateway.networking.k8s.io/v1alpha2", + "/apis/gateway.networking.k8s.io/v1alpha3", + "/apis/networking.istio.io/v1", + "/apis/networking.istio.io/v1beta1", + "/apis/networking.istio.io/v1alpha3", + ]) && tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[9].verbs) == tolist(["get"]) + error_message = "The only non-resource grant must be get on the exact collector discovery URLs" } assert { diff --git a/aws-integration-setup/terraform/modules/k8s-resources/tests/k8s_resources.tftest.hcl b/aws-integration-setup/terraform/modules/k8s-resources/tests/k8s_resources.tftest.hcl index 39ebcda..a381136 100644 --- a/aws-integration-setup/terraform/modules/k8s-resources/tests/k8s_resources.tftest.hcl +++ b/aws-integration-setup/terraform/modules/k8s-resources/tests/k8s_resources.tftest.hcl @@ -26,7 +26,7 @@ run "collector_only_is_the_default" { } assert { - condition = kubernetes_cron_job_v1.k8s_collector[0].spec[0].job_template[0].spec[0].template[0].spec[0].container[0].image == "public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.46.0" + condition = kubernetes_cron_job_v1.k8s_collector[0].spec[0].job_template[0].spec[0].template[0].spec[0].container[0].image == "public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.48.0" error_message = "The collector image must default to the pinned Nullify ECR Public tag" } @@ -135,18 +135,15 @@ run "managed_scan_rbac_only" { } assert { - condition = length(kubernetes_cluster_role_v1.nullify_readonly[0].rule) == 8 - error_message = "The managed-scan ClusterRole must have seven resource rules and one /version rule" + condition = length(kubernetes_cluster_role_v1.nullify_readonly[0].rule) == 10 + error_message = "The managed-scan ClusterRole must have nine resource rules and one discovery rule" } assert { - condition = sum([for rule in slice(kubernetes_cluster_role_v1.nullify_readonly[0].rule, 0, 7) : length(rule.resources)]) == 26 - error_message = "The managed-scan ClusterRole must list exactly the 26 config kinds; secrets are omitted because list returns values" + condition = sum([for rule in slice(kubernetes_cluster_role_v1.nullify_readonly[0].rule, 0, 9) : length(rule.resources)]) == 33 + error_message = "The managed-scan ClusterRole must list exactly the 33 config and routing kinds; secrets are omitted because list returns values" } - # Pins the set, not just the size: swapping one kind for another keeps every - # count assertion green. Anything else that binds this group -- a chart, a - # manifest -- has to grant exactly these kinds. assert { condition = tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[0].api_groups) == tolist([""]) && toset(kubernetes_cluster_role_v1.nullify_readonly[0].rule[0].resources) == toset(["nodes", "namespaces", "pods", "services", "persistentvolumeclaims", "persistentvolumes", "configmaps", "resourcequotas", "limitranges", "serviceaccounts"]) error_message = "Rule 0 must grant exactly the core API group kinds the scanner lists" @@ -183,13 +180,32 @@ run "managed_scan_rbac_only" { } assert { - condition = alltrue([for rule in slice(kubernetes_cluster_role_v1.nullify_readonly[0].rule, 0, 7) : tolist(rule.verbs) == tolist(["list"])]) + condition = tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[7].api_groups) == tolist(["gateway.networking.k8s.io"]) && toset(kubernetes_cluster_role_v1.nullify_readonly[0].rule[7].resources) == toset(["gatewayclasses", "gateways", "httproutes", "referencegrants"]) + error_message = "Rule 7 must grant exactly the four Gateway API routing kinds" + } + + assert { + condition = tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[8].api_groups) == tolist(["networking.istio.io"]) && toset(kubernetes_cluster_role_v1.nullify_readonly[0].rule[8].resources) == toset(["virtualservices", "serviceentries", "destinationrules"]) + error_message = "Rule 8 must grant exactly the three Istio routing kinds" + } + + assert { + condition = alltrue([for rule in slice(kubernetes_cluster_role_v1.nullify_readonly[0].rule, 0, 9) : tolist(rule.verbs) == tolist(["list"])]) error_message = "Resource rules must grant list only" } assert { - condition = tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[7].non_resource_urls) == tolist(["/version"]) && tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[7].verbs) == tolist(["get"]) - error_message = "The only non-resource grant must be get /version" + condition = toset(kubernetes_cluster_role_v1.nullify_readonly[0].rule[9].non_resource_urls) == toset([ + "/version", "/api", "/apis", + "/apis/gateway.networking.k8s.io/v1", + "/apis/gateway.networking.k8s.io/v1beta1", + "/apis/gateway.networking.k8s.io/v1alpha2", + "/apis/gateway.networking.k8s.io/v1alpha3", + "/apis/networking.istio.io/v1", + "/apis/networking.istio.io/v1beta1", + "/apis/networking.istio.io/v1alpha3", + ]) && tolist(kubernetes_cluster_role_v1.nullify_readonly[0].rule[9].verbs) == tolist(["get"]) + error_message = "The only non-resource grant must be get on the exact collector discovery URLs" } assert { From 18bc3445a50a66611de34527968f41cfc6a0ba8e Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 14 Oct 2026 12:05:22 +1100 Subject: [PATCH 5/5] test(context): require exact collector discovery permissions --- scripts/test_routing_rbac.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test_routing_rbac.py b/scripts/test_routing_rbac.py index 2a45b44..60c5b2a 100644 --- a/scripts/test_routing_rbac.py +++ b/scripts/test_routing_rbac.py @@ -66,7 +66,7 @@ def test_routing_permissions(self): ) self.assertIsNotNone(discovery) endpoints = values(discovery[1]) - self.assertTrue(DISCOVERY <= endpoints) + self.assertEqual(endpoints, DISCOVERY | {"/version"}) self.assertFalse(any("*" in endpoint for endpoint in endpoints)) verb = re.match( r"\s*verbs\s*[:=]\s*\[([^\]]*)\]",