diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml index cd2650b..12a95c6 100644 --- a/.github/workflows/helm-release.yml +++ b/.github/workflows/helm-release.yml @@ -6,6 +6,9 @@ on: - "helm-charts/**" - "manifests/**" - "scripts/publish-helm-repo.sh" + - "scripts/verify-published-helm-repo.py" + - "scripts/test_verify_published_helm_repo.py" + - "artifacthub-repo.yml" - "scripts/test-helm-charts.sh" - "scripts/check-rbac-manifest.sh" - "scripts/test_routing_rbac.py" @@ -17,6 +20,9 @@ on: - "helm-charts/**" - "manifests/**" - "scripts/publish-helm-repo.sh" + - "scripts/verify-published-helm-repo.py" + - "scripts/test_verify_published_helm_repo.py" + - "artifacthub-repo.yml" - "scripts/test-helm-charts.sh" - "scripts/check-rbac-manifest.sh" - "scripts/test_routing_rbac.py" @@ -71,6 +77,9 @@ jobs: - name: Check routing collector RBAC run: python3 scripts/test_routing_rbac.py + - name: Test public repository consumer verification + run: python3 scripts/test_verify_published_helm_repo.py + - name: Lint and render run: scripts/test-helm-charts.sh "$CHART" @@ -268,6 +277,9 @@ jobs: - name: Deploy to GitHub Pages uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + - name: Verify publicly downloadable index and chart packages + run: python3 scripts/verify-published-helm-repo.py --expected-index _site/index.yaml --expected-repository-metadata artifacthub-repo.yml --attempts 20 + - name: Fail when a chart was skipped run: | if [ -s skipped.txt ]; then diff --git a/.github/workflows/terraform-validate.yml b/.github/workflows/terraform-validate.yml index 8ea47bd..ece169d 100644 --- a/.github/workflows/terraform-validate.yml +++ b/.github/workflows/terraform-validate.yml @@ -106,6 +106,7 @@ jobs: - 1.8.5 - 1.10.5 directory: + - gcp-integration-setup/terraform/modules/nullify-gcp-integration - aws-integration-setup/terraform/modules/eks-managed-scan-access - aws-integration-setup/terraform/modules/k8s-resources - aws-integration-setup/terraform/modules/nullify-aws-integration @@ -148,6 +149,9 @@ jobs: - name: compare Allow/Deny action sets run: python3 scripts/check_iam_policy_drift.py + - name: Check gcloud installer permissions + run: python3 -B scripts/test_gcp_installer.py + - name: Check routing collector RBAC run: python3 scripts/test_routing_rbac.py diff --git a/.gitignore b/.gitignore index 876856c..dec9e37 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,7 @@ values-secret.yml /published.txt /release.txt /skipped.txt + +# Python script caches +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 4a3bcd0..3bd3758 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ๐Ÿ›ก๏ธ Nullify Cloud Connector - Infrastructure Templates -**Complete infrastructure templates for deploying Nullify's cloud security integrations across Kubernetes and AWS.** +**Complete infrastructure templates for deploying Nullify's cloud security integrations across AWS, GCP, Azure, and supported Kubernetes collection paths.** > ๐Ÿšจ **SECURITY NOTICE**: This repository contains **GENERIC TEMPLATES ONLY**. Contact Nullify support for production configuration values. @@ -9,10 +9,11 @@ This repository provides comprehensive infrastructure-as-code templates for integrating Nullify's security platform with your cloud environment. It includes multiple deployment options to suit different infrastructure preferences and requirements. ### **What's Included** -- โš™๏ธ **Helm Charts** - Production-ready Kubernetes deployment for **EKS (IRSA)** and **GKE (Workload Identity)** +- โš™๏ธ **Helm Charts** - Production-ready Kubernetes deployment for **EKS (IRSA)** and **GKE (projected Kubernetes service-account tokens)** - ๐Ÿ—๏ธ **CloudFormation Templates** - AWS infrastructure setup with IAM roles and policies - ๐Ÿ”ง **Terraform Modules** - Modular infrastructure-as-code for AWS and multi-cluster EKS integration - โ˜๏ธ **GCP Terraform** - Read-only GCP integration via Workload Identity Federation +- **Azure Terraform** - Azure subscription or management-group Reader access via workload federation, with optional directory permissions - ๐Ÿค– **GitHub Actions** - Automated chart publishing and validation - ๐Ÿ“š **Documentation** - Comprehensive setup and security guides - โŒ **NO real sensitive data, bucket names, or account IDs** @@ -42,7 +43,7 @@ This repository provides comprehensive infrastructure-as-code templates for inte | **๐Ÿ—๏ธ CloudFormation** | AWS-centric infrastructure, ClickOps teams | AWS CLI, appropriate IAM permissions | | **๐Ÿ”ง Terraform (AWS)** | Infrastructure-as-code, multi-cluster teams | Terraform, AWS provider configured | | **โ˜๏ธ Terraform (GCP)** | GCP environments, Workload Identity Federation | Terraform, `gcloud` auth on the host project, org or folder admin access | -| **โ˜๏ธ Terraform (GKE collector)** | GKE clusters running the Nullify k8s-collector | Terraform, GKE with Workload Identity enabled | +| **โ˜๏ธ Terraform (GKE service account)** | Optional GCP identity resources; not needed for the standard projected-token collector | Terraform; confirm the alternate identity requirement with Nullify | ### **GCP Quick Start** @@ -55,15 +56,21 @@ $EDITOR terraform.tfvars # paste values from the Nullify console terraform init && terraform apply ``` -Then paste the `service_account_email` and `workload_identity_provider` outputs into the Nullify console under **Settings โ†’ Cloud Integrations โ†’ GCP** and click **Verify**. +Then paste the `service_account_email` and `workload_identity_provider` outputs into the Nullify console under **Settings โ†’ Connectors โ†’ GCP**, select **Save changes**, then **Validate**. Validation checks the saved identity. Confirm fresh inventory separately. See [`gcp-integration-setup/terraform/README.md`](gcp-integration-setup/terraform/README.md) for the full guide, the org / folder / project scope options, and the gcloud-only installer. +### Azure quick start + +Open `azure-integration-setup/terraform`, copy `terraform.tfvars.example`, and fill in the tenant-specific values supplied by Nullify. Choose management-group or subscription scope, review `terraform plan`, then apply it. Paste `entra_tenant_id` and `application_client_id` into **Settings โ†’ Connectors โ†’ Azure** with Workload Identity Federation selected. + +Azure cloud access is separate from Entra sign-in, Azure DevOps, and Teams. The default module grants ARM Reader; optional directory permissions require separate administrator consent. See the [Azure module guide](azure-integration-setup/terraform/README.md). + ### **Prerequisites** **All methods:** 1. **Nullify Account** and dashboard access -2. Log in to **Configure > Integrations** to obtain provider-specific values +2. Log in to **Settings โ†’ Connectors** to obtain provider-specific values **AWS integration:** - AWS account with IAM permissions @@ -78,6 +85,12 @@ See [`gcp-integration-setup/terraform/README.md`](gcp-integration-setup/terrafor --- +## Helm release compatibility + +The public Helm index now serves collector chart **0.6.0** with image **4.0.0** and readonly-access chart **0.1.0**. Publication was verified on 11 November 2026 after releasing the queued GitHub Pages workflow. Collector **0.5.0**, with image **3.48.0**, remains available for the existing backend contract. Collector 4 requires `collector.s3.keyPrefix` from a compatible backend. Confirm the backend rollout and exact `collectorUploadPrefix` with Nullify before upgrading. Do not infer the prefix or upgrade only the image. + +The current CloudFormation and Terraform collector paths also require the source-prefix contract; pinning Helm 0.5.0 does not make current IAM templates compatible with an older backend. Use the complete template/chart version set confirmed by Nullify. + ## โš™๏ธ **Helm Chart Deployment** ### **Option 1: Install from Helm Repository (Recommended)** @@ -102,7 +115,7 @@ serviceAccount: EOF # 3. Install the chart -helm install nullify-collector nullify/nullify-k8s-collector \ +helm install nullify-collector nullify/nullify-k8s-collector --version 0.5.0 \ -f values-production.yaml \ --namespace nullify \ --create-namespace @@ -113,6 +126,8 @@ kubectl get all -n nullify ### **Option 2: Install from Source** +Obtain the exact `collectorUploadPrefix` and confirm source-version compatibility with Nullify before following this path. + ```bash # 1. Clone this repository git clone https://github.com/Nullify-Platform/nullify-cloud-connector.git @@ -360,7 +375,7 @@ nullify-cloud-connector/ โ”œโ”€โ”€ ๐Ÿ”ง terraform/ # Terraform Modules โ”‚ โ”œโ”€โ”€ modules/ โ”‚ โ”‚ โ”œโ”€โ”€ nullify-gcp-integration/ # WIF pool, provider, SA, IAM bindings - โ”‚ โ”‚ โ””โ”€โ”€ nullify-gke-collector/ # GKE k8s-collector deployment + โ”‚ โ”‚ โ””โ”€โ”€ nullify-gke-collector/ # Optional GCP service-account identity resources โ”‚ โ”œโ”€โ”€ examples/ # Example configurations โ”‚ โ”œโ”€โ”€ terraform.tfvars.example # Example configuration โ”‚ โ””โ”€โ”€ README.md # GCP Terraform documentation @@ -374,7 +389,7 @@ nullify-cloud-connector/ | Component | Purpose | Use When | |-----------|---------|----------| -| **๐ŸŽฏ Helm Charts** | Deploy K8s collector (EKS via IRSA, GKE via Workload Identity) | You have EKS or GKE and prefer K8s-native tools | +| **๐ŸŽฏ Helm Charts** | Deploy K8s collector (EKS via IRSA, GKE via projected service-account tokens) | You have EKS or GKE and prefer K8s-native tools | | **๐Ÿ—๏ธ CloudFormation** | Set up AWS IAM roles and policies | You prefer AWS-native infrastructure | | **๐Ÿ”ง Terraform** | Modular infrastructure-as-code with multi-cluster support | You use Terraform for infrastructure | | **๐Ÿค– GitHub Actions** | Automated testing and publishing | You want CI/CD for chart updates | @@ -405,7 +420,7 @@ serviceAccount: ### **Basic Configuration** -All required values are provided in the Nullify configure page. +For chart 0.6.0, use the exact source-qualified prefix supplied by a compatible Nullify deployment. Replace every placeholder below; if the prefix is unavailable, stop and request compatible deployment values. Use the earlier 0.5.0 quick start only with the matching existing backend and IAM policy. ```yaml collector: @@ -414,7 +429,7 @@ collector: clusterName: "your-cluster-name" # Must match your actual EKS cluster name s3: bucket: "your-nullify-bucket" - keyPrefix: "k8s-collector" + keyPrefix: "k8s-collector/YOUR-SOURCE-TENANT-ID/" kms: keyArn: "arn:aws:kms:us-west-2:123456789012:key/your-key-id" schedule: "0 2 * * *" # Daily at 2 AM UTC @@ -473,9 +488,12 @@ Each release is tagged `-v` (for example `nullify-k8s-collector- Collector releases up to 0.2.0 were tagged `vX.Y.Z`; only 0.2.0 of those is still in the repository. ### **Deployment Management** + +The following command keeps the 0.5.0 contract. To upgrade to 0.6.0, first complete the [collector 4 cutover](#collector-4-source-prefix-cutover), including paired backend, IAM policy and exact prefix. + ```bash -# Upgrade the chart -helm upgrade nullify-collector nullify/nullify-k8s-collector \ +# Keep the version used by the existing-backend quick start +helm upgrade nullify-collector nullify/nullify-k8s-collector --version 0.5.0 \ -f values-production.yaml \ --namespace nullify diff --git a/artifacthub-repo.yml b/artifacthub-repo.yml new file mode 100644 index 0000000..98bcadf --- /dev/null +++ b/artifacthub-repo.yml @@ -0,0 +1 @@ +repositoryID: 158135dc-d5cd-4016-9d22-5494d9510594 diff --git a/aws-integration-setup/cloudformation/README.md b/aws-integration-setup/cloudformation/README.md index 8481776..7f3f048 100644 --- a/aws-integration-setup/cloudformation/README.md +++ b/aws-integration-setup/cloudformation/README.md @@ -18,7 +18,7 @@ This template creates: 1. **Obtain Configuration Values from Nullify Configure Page**: - Log in to your Nullify configure page - - Navigate to Configure > Integrations + - Navigate to **Settings โ†’ Connectors โ†’ AWS** - Select AWS integration to begin setup - Note the provided values: - External ID for your account @@ -35,7 +35,7 @@ This template creates: - OIDC provider URL (see [Getting EKS OIDC URL](#getting-eks-oidc-url) below) - **Helm charts or Kubernetes manifests** to deploy the collector cronjobs -> ๐Ÿ“– **Reference**: For detailed setup instructions, see the [Nullify AWS Integration Documentation](https://docs.nullify.ai/integrations/aws/configuration). +> ๐Ÿ“– **Reference**: For detailed setup instructions, see the [Nullify AWS Integration Documentation](https://docs.nullify.ai/connectors/aws/configuration). **Alternative**: Contact Nullify Support for assistance with configuration values. @@ -68,10 +68,7 @@ oidc.eks.us-west-2.amazonaws.com/id/ABCDEF1234567890ABCDEF1234567890 3. Go to the **Configuration** tab 4. Under **Details**, look for **OpenID Connect provider URL** -### Method 3: kubectl (if you have cluster access) -```bash -kubectl get configmap aws-auth -n kube-system -o yaml | grep "oidc" -``` +The `aws-auth` ConfigMap does not supply the cluster OIDC issuer. Use the AWS CLI or EKS console methods above. ### OIDC URL Format - **Full URL format**: `https://oidc.eks.REGION.amazonaws.com/id/CLUSTER_ID` @@ -127,6 +124,8 @@ aws cloudformation describe-stacks \ ### 3. Deploy with EKS Integration +The current EKS collector template requires the exact `CollectorUploadPrefix` from a compatible Nullify backend. Confirm the paired backend rollout and deploy collector 4 with the same prefix. If the value is unavailable, stop and ask Nullify for a complete compatible version set; do not combine the current role policy with a 3.x collector. See the [cutover and rollback requirements](../../README.md#collector-4-source-prefix-cutover). Replace the prefix and KMS placeholders below with supplied deployment values. + ```bash # For EKS clusters, enable integration # First, get your OIDC URL (see "Getting EKS OIDC URL" section above) @@ -142,6 +141,8 @@ aws cloudformation create-stack \ ParameterKey=CrossAccountRoleArn,ParameterValue=arn:aws:iam::NULLIFY-ACCOUNT:role/NULLIFY-ROLE \ ParameterKey=NullifyS3Bucket,ParameterValue=NULLIFY-BUCKET \ ParameterKey=EnableEKSIntegration,ParameterValue=true \ + ParameterKey=CollectorUploadPrefix,ParameterValue=k8s-collector/YOUR-SOURCE-TENANT-ID/ \ + ParameterKey=NullifyKMSKeyArn,ParameterValue=arn:aws:kms:us-west-2:123456789012:key/YOUR-KEY-ID \ ParameterKey=EKSOidcProviderURL,ParameterValue=$OIDC_URL \ --capabilities CAPABILITY_NAMED_IAM @@ -155,7 +156,9 @@ aws cloudformation create-stack \ ParameterKey=CrossAccountRoleArn,ParameterValue=arn:aws:iam::NULLIFY-ACCOUNT:role/NULLIFY-ROLE \ ParameterKey=NullifyS3Bucket,ParameterValue=NULLIFY-BUCKET \ ParameterKey=EnableEKSIntegration,ParameterValue=true \ - ParameterKey=EKSOidcProviderURL,ParameterValue=A78D8794A06CAE5791C5812CDB164C7D.gr7.ap-southeast-2.eks.amazonaws.com \ + ParameterKey=CollectorUploadPrefix,ParameterValue=k8s-collector/YOUR-SOURCE-TENANT-ID/ \ + ParameterKey=NullifyKMSKeyArn,ParameterValue=arn:aws:kms:us-west-2:123456789012:key/YOUR-KEY-ID \ + ParameterKey=EKSOidcProviderURL,ParameterValue=oidc.eks.us-west-2.amazonaws.com/id/YOUR-OIDC-ID \ --capabilities CAPABILITY_NAMED_IAM ``` diff --git a/aws-integration-setup/terraform-v6/README.md b/aws-integration-setup/terraform-v6/README.md index 5015656..c2a63af 100644 --- a/aws-integration-setup/terraform-v6/README.md +++ b/aws-integration-setup/terraform-v6/README.md @@ -19,6 +19,10 @@ ephemeral "aws_eks_cluster_auth" "primary" { token = ephemeral.aws_eks_cluster_auth.primary.token ``` +## Collector version compatibility + +The current `k8s-resources` module deploys collector 4.0.0 and requires `collector_upload_prefix` whenever the collector is enabled. Obtain the exact `collectorUploadPrefix` and confirm the paired Nullify backend rollout first. Pass that same prefix to the AWS integration role module and each collector module. Do not apply current collector templates to an older backend or upgrade only the image. See the [cutover and rollback requirements](../../README.md#collector-4-source-prefix-cutover). Cloud-only and managed-scan configurations do not need a collector upload prefix. + ## Requirements - Terraform >= 1.8 (>= 1.10 for the `multi-cluster-complete` and `managed-scan` examples) @@ -105,6 +109,7 @@ module "nullify_eks_access" { - `aws_region`: AWS region for IAM resources (default: ap-southeast-2) - `s3_bucket_name`: S3 bucket for scan results (optional) - `nullify_s3_access_point_arn`: S3 access point for collector uploads (optional) +- `collector_upload_prefix`: Exact source prefix from Nullify; required on collector module instances and passed to the IAM module for collector 4 - `kms_key_arn`: KMS key ARN or alias ARN from the Nullify configure page; see "KMS" in `../terraform/README.md`. Optional for this module; required by `k8s-resources` whenever `enable_collector` is true - `enable_kubernetes_integration`: Set to `true` for EKS integration - `eks_cluster_arns`: List of EKS cluster ARNs to integrate with, in any region @@ -115,11 +120,11 @@ module "nullify_eks_access" { - `tags`: Resource tags - `cronjob_schedule`: Deprecated and unused here; set the schedule on `k8s-resources` -`k8s-resources` requires `cluster_name` and `kms_key_arn` whenever `enable_collector` is true, with no unencrypted opt-out. `cluster_name` must match your actual cluster name exactly, must match a cluster registered on the Nullify configure page, and must differ per cluster: the collector uploads to `/k8s-collector/-data.json`, and Nullify joins that upload on the name. See "Kubernetes Resources" and "KMS" in `../terraform/README.md`. +`k8s-resources` requires `cluster_name`, `kms_key_arn` and `collector_upload_prefix` whenever `enable_collector` is true, with no unencrypted opt-out. `cluster_name` must match your actual cluster name exactly, must match a cluster registered on the Nullify configure page, and must differ per cluster: the collector uploads to `/k8s-collector/-data.json`, and Nullify joins that upload on the name. See "Kubernetes Resources" and "KMS" in `../terraform/README.md`. A cluster cannot run the collector and the managed scan at once: the two paths register it under different identities, duplicating its inventory. This is enforced by `eks-managed-scan-access`'s `collector_cluster_arns` variable, not by a `k8s-resources` flag combination -- `enable_collector` and `enable_managed_scan_rbac` may be set together on one `k8s-resources` instance to stage a cutover, since the ClusterRole it applies is inert until an access entry uses it. See "Kubernetes Resources" in `../terraform/README.md`. -`k8s-resources` defaults `collector_image` to `public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.48.1`, the same tag the Helm chart deploys. Replace any explicit `nullify/k8s-collector:latest`: Nullify does not publish that Docker Hub image. +`k8s-resources` defaults `collector_image` to `public.ecr.aws/w4o2j2x4/integrations:k8s-collector-4.0.0`, the same tag as Helm chart 0.6.0. Both require the collector 4 source-prefix contract. Replace any explicit `nullify/k8s-collector:latest`: Nullify does not publish that Docker Hub image. ## Upgrade Notes diff --git a/aws-integration-setup/terraform/README.md b/aws-integration-setup/terraform/README.md index f2de54e..911bb78 100644 --- a/aws-integration-setup/terraform/README.md +++ b/aws-integration-setup/terraform/README.md @@ -8,6 +8,10 @@ This directory targets AWS provider v5. If your project uses AWS provider v6, us **EKS Integration**: The AWS integration module only sets up IAM roles and trust policies. Kubernetes access needs one of the two scan modes in [Kubernetes scan modes](#kubernetes-scan-modes). +## Collector version compatibility + +The current `k8s-resources` module deploys collector 4.0.0 and requires `collector_upload_prefix` whenever the collector is enabled. Obtain the exact `collectorUploadPrefix` and confirm the paired Nullify backend rollout first. Pass that same prefix to the AWS integration role module and each collector module. Do not apply current collector templates to an older backend or upgrade only the image. See the [cutover and rollback requirements](../../README.md#collector-4-source-prefix-cutover). Cloud-only and managed-scan configurations do not need a collector upload prefix. + ## Requirements - Terraform >= 1.8 (the version CI validates and runs the module test suites at) @@ -87,7 +91,7 @@ terraform/ | AWS setup | `enable_kubernetes_integration = true`, S3 bucket or access point | `eks-managed-scan-access`: one access entry per cluster | | Kubernetes setup | `k8s-resources` (default) or the `nullify-k8s-collector` Helm chart | A list-only `nullify-readonly` ClusterRole and ClusterRoleBinding, applied by `k8s-resources` with `enable_collector = false, enable_managed_scan_rbac = true` | | Kinds read | The managed scan's kinds, plus ingressclasses, horizontalpodautoscalers, poddisruptionbudgets, storageclasses, csidrivers, runtimeclasses, priorityclasses, flowschemas, certificatesigningrequests and customresourcedefinitions. `k8s-resources` and chart 0.4.0 and later grant the same set, `list` only. Charts before 0.4.0 grant an older set; see that version's `templates/clusterrole.yaml` | See [RBAC granted to Nullify](#rbac-granted-to-nullify-authorization--rbac) | -| Upgrades | You update the image | None | +| Upgrades | Upgrade the backend, IAM prefix grant and collector together | None | Both modes can run on the same role: IRSA uses `sts:AssumeRoleWithWebIdentity`, the managed scan uses `sts:AssumeRole` with the external ID. @@ -275,6 +279,7 @@ terraform init && terraform apply - `aws_region`: AWS region for IAM resources (default: ap-southeast-2) - `s3_bucket_name`: S3 bucket for scan results (optional) - `nullify_s3_access_point_arn`: S3 access point for collector uploads (optional). Grants `s3:PutObject` and `s3:PutObjectAcl` on `/object/k8s-collector/*` next to the bucket grants +- `collector_upload_prefix`: Exact source prefix from Nullify; required on collector module instances and passed to the IAM module for collector 4 - `kms_key_arn`: KMS ARN from the Nullify configure page, see [KMS](#kms). Optional for this module; required by `k8s-resources` whenever `enable_collector` is true - `kubernetes_namespace`: Kubernetes namespace name (default: nullify) - `service_account_name`: Collector service account name (default: nullify-k8s-collector-sa) @@ -296,6 +301,8 @@ A key ARN is preferred because it avoids the wildcard. IAM does not resolve an a ## Module Usage +Replace every placeholder in these examples with deployment values from Nullify, including the exact `collectorUploadPrefix`. The collector examples require the collector 4 backend and matching role policy. + ### AWS Integration Only ```hcl module "nullify_aws_integration" { @@ -311,6 +318,7 @@ module "nullify_aws_integration" { # Optional EKS integration enable_kubernetes_integration = true + collector_upload_prefix = "k8s-collector/YOUR-SOURCE-TENANT-ID/" eks_cluster_arns = [ "arn:aws:eks:us-west-2:123456789012:cluster/my-cluster" ] @@ -328,6 +336,7 @@ module "k8s_resources" { iam_role_arn = module.nullify_aws_integration.role_arn cluster_name = "acme-prod-use1" + collector_upload_prefix = "k8s-collector/YOUR-SOURCE-TENANT-ID/" s3_bucket_name = "my-scan-results-bucket" # or the S3 access point ARN from Nullify kms_key_arn = "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012" aws_region = "us-west-2" @@ -347,7 +356,7 @@ This is the same value as `collector.clusterName` in the `nullify-k8s-collector` A cluster cannot run the collector and the managed scan at once: the collector's upload registers it as on-prem keyed on `cluster_name`, the managed scan registers the same cluster as its EKS ARN, and nothing joins the two, so running both lists the cluster twice with its pods and containers duplicated. This is enforced by `eks-managed-scan-access`'s `collector_cluster_arns` variable, not by a `k8s-resources` flag combination: pass it the cluster ARNs where `enable_collector` is `true` and the module fails at plan for any of them. To change mode, apply the new one and ask Nullify to remove the old cluster registration. -`collector_image` defaults to `public.ecr.aws/w4o2j2x4/integrations:k8s-collector-3.48.1`, a pinned tag of Nullify's ECR Public image. The `nullify-k8s-collector` Helm chart defaults to the same tag, so both install paths run the same collector. Earlier versions defaulted to `nullify/k8s-collector:latest` on Docker Hub, which Nullify does not publish; if you set that value explicitly, replace it. +`collector_image` defaults to `public.ecr.aws/w4o2j2x4/integrations:k8s-collector-4.0.0`, a pinned tag of Nullify's ECR Public image. The `nullify-k8s-collector` chart 0.6.0 defaults to the same tag. Both require the collector 4 source-prefix contract. Earlier versions defaulted to `nullify/k8s-collector:latest` on Docker Hub, which Nullify does not publish; if you set that value explicitly, replace it. ## Outputs diff --git a/docs/artifact-hub-registration.json b/docs/artifact-hub-registration.json new file mode 100644 index 0000000..3b95398 --- /dev/null +++ b/docs/artifact-hub-registration.json @@ -0,0 +1,11 @@ +{ + "kind": "Helm charts", + "name": "nullify", + "displayName": "Nullify Kubernetes", + "url": "https://nullify-platform.github.io/nullify-cloud-connector/", + "disabled": false, + "scannerDisabled": false, + "repositoryID": "158135dc-d5cd-4016-9d22-5494d9510594", + "status": "registered; indexing pending", + "registeredOn": "2026-11-11" +} diff --git a/docs/marketplace-publication.md b/docs/marketplace-publication.md new file mode 100644 index 0000000..f9b100b --- /dev/null +++ b/docs/marketplace-publication.md @@ -0,0 +1,22 @@ +# Cloud package publication status + +On 11 November 2026, the current-main Helm publication workflow completed and both charts became publicly downloadable: + +| Chart | Version | Package SHA256 | +| --- | --- | --- | +| nullify-k8s-collector | 0.6.0 | 9c226f4fdf56f9d717413407fa7271b307096c51c3e658f3a65ff7a7554c8d44 | +| nullify-k8s-readonly-access | 0.1.0 | 6affcdb7b182211001b6c8feaff4089e41bf6172abe6f2afca04d28ed4ca1f3a | + +[Publication run](https://github.com/Nullify-Platform/nullify-cloud-connector/actions/runs/42922224437) used main commit `54681d5f5b6fb9ffd19380081edda5bcd23f995e`. Both downloaded packages matched the [public index](https://nullify-platform.github.io/nullify-cloud-connector/index.yaml) digests and rendered successfully. Existing 0.5.0 remains in the index with its original digest. + +The stale 4 and 5 November runs were waiting/pending behind the protected GitHub Pages environment. They were cancelled before dispatching and approving publication from current main; environment protections were preserved. + +Collector 4 publication does not activate a compatible backend or provision upload trust. Existing tenants need the exact source-qualified upload prefix and coordinated IAM/backend rollout before upgrading. The readonly-access chart only grants Kubernetes API access; it does not install a workload or establish an EKS access entry. + +Artifact Hub registration remains separate from publishing a Helm repository. Terraform Registry and commercial Azure/GCP marketplace offers also require their own repository, publisher and fulfillment requirements; they are not created by this workflow. + +The marketplace readiness PR prepares collector chart 0.6.1 because its README is part of the immutable chart package. It retains collector image 4.0.0 and the source-prefix contract. Published 0.6.0 remains unchanged. This patch is not yet published; publication requires Tim's approval. + +Artifact Hub repository `nullify` (display name Nullify Kubernetes) was registered with Tim's explicit approval on 11 November 2026. Its assigned repository ID is `158135dc-d5cd-4016-9d22-5494d9510594`; processing is pending. The registration leaves security scanning enabled. The ownership metadata in `artifacthub-repo.yml` still needs to be served next to `index.yaml` before a Verified Publisher claim is established. No other marketplace submission or vendor message is authorized by this registration approval. + +The prepared release now includes a public consumer check for index versions, package digests, chart identities and Artifact Hub ownership metadata. A full local repository dry run retained all four published chart versions and prepared collector 0.6.1 without replacing immutable 0.6.0. Routing RBAC, upload-scope tests, chart lint/render cases and the readonly raw-manifest comparison passed. These checks do not establish a live customer installation or publish the prepared version. diff --git a/gcp-integration-setup/docs/permissions.md b/gcp-integration-setup/docs/permissions.md index 99c4544..7447681 100644 --- a/gcp-integration-setup/docs/permissions.md +++ b/gcp-integration-setup/docs/permissions.md @@ -15,8 +15,9 @@ Cloud Connector requests, and why. Use it to satisfy security review. - The pool's attribute condition pins trust to your specific Nullify tenant id (`assertion.tenant_id == ""`). Any other Nullify tenant's token is rejected by GCP before any permission check - happens โ€” so even if Nullify's signing key were stolen, an attacker - could not mint a token accepted by another tenant's provider. + happens. This check trusts the tenant claim in a token signed by Nullify. + A compromised signing key can forge that claim. Key protection, rotation + and revocation remain required. ## Predefined roles @@ -29,7 +30,6 @@ Cloud Connector requests, and why. Use it to satisfy security review. | `roles/cloudsql.viewer` | Cloud SQL instance + replica config. | | `roles/spanner.viewer` | Spanner instance + database config. | | `roles/cloudkms.viewer` | KMS key ring + crypto key config (no key material). | -| `roles/logging.viewer` | Logging sink + exclusion config (no log content). | | `roles/run.viewer` | Cloud Run service + revision config. | | `roles/cloudfunctions.viewer` | Cloud Functions config. | | `roles/appengine.appViewer` | App Engine service + version config. | @@ -40,10 +40,11 @@ Cloud Connector requests, and why. Use it to satisfy security review. ## Custom role: `nullifyCloudConnector` Read-only permissions on services that don't have a predefined viewer role. -Strict allowlist of `*.get` / `*.list` only. +Explicit allowlist of read-only permissions. | Permission | Purpose | | --- | --- | +| `logging.sinks.list`, `logging.logMetrics.list`, `logging.buckets.list` | List sink, log-based metric and log-bucket configuration used by the collector. Does not grant `logging.logEntries.list`. | | `compute.securityPolicies.get/list` | Cloud Armor WAF rule discovery. | | `accesscontextmanager.servicePerimeters.get/list` * | VPC Service Controls perimeters. | | `orgpolicy.policies.list` + `orgpolicy.policy.get` * | Organisation policy discovery. | @@ -65,6 +66,8 @@ Strict allowlist of `*.get` / `*.list` only. \* Permissions marked with an asterisk are only includable in **organisation-scoped** custom roles โ€” GCP rejects them in a project-scoped custom role because the underlying resources live at the organisation. They are silently omitted when this module is deployed without `organization_id` (single-project installs); a single-project install simply has no VPC SC or org-policy data to read. +Earlier module versions granted `roles/logging.viewer`, which includes log-entry reads. This module replaces that grant with the three logging metadata-list permissions above. Existing Terraform installations keep their old grant until an administrator applies the updated Terraform and confirms its removal. The gcloud installer stops adding that role but does not remove existing grants. For a script-managed installation, review the service accountโ€™s existing organization IAM bindings and remove the old Logs Viewer grant explicitly after confirming that no other workload requires it. The uninstall script retains removal of this legacy grant. Review the current [Google Cloud Logging role definition](https://docs.cloud.google.com/iam/docs/roles-permissions/logging) when comparing plans. + ## What Nullify cannot do | Capability | Granted? | Why not | diff --git a/gcp-integration-setup/scripts/install.sh b/gcp-integration-setup/scripts/install.sh index 92c8225..bde6fba 100755 --- a/gcp-integration-setup/scripts/install.sh +++ b/gcp-integration-setup/scripts/install.sh @@ -104,7 +104,7 @@ CUSTOM_ROLE_TITLE="Nullify Cloud Connector (read-only)" CUSTOM_ROLE_DESCRIPTION="Read-only access to security-relevant config Nullify needs that is not covered by predefined viewer roles." # shellcheck disable=SC2089 -CUSTOM_ROLE_PERMISSIONS="compute.securityPolicies.get,compute.securityPolicies.list,accesscontextmanager.servicePerimeters.get,accesscontextmanager.servicePerimeters.list,orgpolicy.policies.list,orgpolicy.policy.get,alloydb.clusters.get,alloydb.clusters.list,alloydb.instances.get,alloydb.instances.list,file.instances.get,file.instances.list,redis.instances.get,redis.instances.list,memcache.instances.get,memcache.instances.list,artifactregistry.repositories.get,artifactregistry.repositories.list,dns.managedZones.get,dns.managedZones.list,dns.resourceRecordSets.list,apigateway.gateways.get,apigateway.gateways.list,apigateway.apis.get,apigateway.apis.list,apigateway.apiconfigs.get,apigateway.apiconfigs.list,storage.buckets.get,storage.buckets.list,storage.buckets.getIamPolicy,secretmanager.secrets.get,secretmanager.secrets.list,bigquery.datasets.get,bigquery.tables.get,bigquery.tables.list,bigquery.routines.get,bigquery.routines.list,batch.jobs.get,batch.jobs.list,workflows.workflows.get,workflows.workflows.list,datastore.databases.getMetadata,datastore.databases.list,aiplatform.endpoints.get,aiplatform.endpoints.list,securitycenter.sources.get,securitycenter.sources.list" +CUSTOM_ROLE_PERMISSIONS="logging.sinks.list,logging.logMetrics.list,logging.buckets.list,compute.securityPolicies.get,compute.securityPolicies.list,accesscontextmanager.servicePerimeters.get,accesscontextmanager.servicePerimeters.list,orgpolicy.policies.list,orgpolicy.policy.get,alloydb.clusters.get,alloydb.clusters.list,alloydb.instances.get,alloydb.instances.list,file.instances.get,file.instances.list,redis.instances.get,redis.instances.list,memcache.instances.get,memcache.instances.list,artifactregistry.repositories.get,artifactregistry.repositories.list,dns.managedZones.get,dns.managedZones.list,dns.resourceRecordSets.list,apigateway.gateways.get,apigateway.gateways.list,apigateway.apis.get,apigateway.apis.list,apigateway.apiconfigs.get,apigateway.apiconfigs.list,storage.buckets.get,storage.buckets.list,storage.buckets.getIamPolicy,secretmanager.secrets.get,secretmanager.secrets.list,bigquery.datasets.get,bigquery.tables.get,bigquery.tables.list,bigquery.tables.getIamPolicy,pubsub.topics.getIamPolicy,bigquery.routines.get,bigquery.routines.list,batch.jobs.get,batch.jobs.list,workflows.workflows.get,workflows.workflows.list,datastore.databases.getMetadata,datastore.databases.list,aiplatform.endpoints.get,aiplatform.endpoints.list,securitycenter.sources.get,securitycenter.sources.list" echo "==> Creating organisation custom role ${CUSTOM_ROLE_ID}" if gcloud iam roles describe "${CUSTOM_ROLE_ID}" --organization="${NULLIFY_ORG_ID}" >/dev/null 2>&1; then @@ -125,6 +125,7 @@ fi echo "==> Granting predefined viewer roles at the organisation" ROLES=( + roles/browser roles/cloudasset.viewer roles/iam.securityReviewer roles/compute.viewer @@ -132,7 +133,6 @@ ROLES=( roles/cloudsql.viewer roles/spanner.viewer roles/cloudkms.viewer - roles/logging.viewer roles/run.viewer roles/cloudfunctions.viewer roles/appengine.appViewer @@ -151,6 +151,9 @@ done echo echo "Nullify GCP integration installed successfully." echo -echo "Paste these values into the Nullify console under Settings -> Cloud Integrations -> GCP:" +echo "Paste these values into the Nullify console under Settings -> Connectors -> GCP:" echo " Service Account Email: ${SA_EMAIL}" echo " Workload Identity Provider: projects/$(gcloud projects describe "${NULLIFY_HOST_PROJECT}" --format='value(projectNumber)')/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}" + +echo "Save the connection, then select Validate and confirm the first inventory result." +echo "For older installations, review and remove the previous roles/logging.viewer grant separately." diff --git a/gcp-integration-setup/scripts/uninstall.sh b/gcp-integration-setup/scripts/uninstall.sh index 73f3aea..e28c130 100755 --- a/gcp-integration-setup/scripts/uninstall.sh +++ b/gcp-integration-setup/scripts/uninstall.sh @@ -16,6 +16,7 @@ SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com" CUSTOM_ROLE_ID="nullifyCloudConnector" ROLES=( + roles/browser roles/cloudasset.viewer roles/iam.securityReviewer roles/compute.viewer diff --git a/gcp-integration-setup/terraform/README.md b/gcp-integration-setup/terraform/README.md index f6cd322..035c91d 100644 --- a/gcp-integration-setup/terraform/README.md +++ b/gcp-integration-setup/terraform/README.md @@ -16,8 +16,7 @@ secrets. Artifact Registry, Cloud Storage, Secret Manager, BigQuery, Cloud Batch, Cloud Workflows, Firestore, Vertex AI, Security Command Center). Defined at the org for `scope = "organization" | "folder"`, - at the project for `scope = "projects"`. Strict allowlist of `*.get` / - `*.list` only โ€” no data-plane reads (no object/secret/row/document + at the project for `scope = "projects"`. Explicit custom-role allowlist of read-only permissions โ€” no data-plane reads (no object/secret/row/document contents, no execution payloads, no inference, no findings). - IAM bindings granting the Nullify service account a curated set of predefined viewer roles plus the custom role above. Bound at organisation @@ -32,12 +31,17 @@ secrets. ## What this does NOT provision -- No data-plane permissions. Nullify can list buckets but cannot read - objects. Nullify can list secrets but cannot read secret payloads. - Nullify can list BigQuery datasets but cannot read table rows. +- No Cloud Storage object reads, Secret Manager secret payload reads or + BigQuery table-row reads through the custom role. The custom + logging metadata list permissions replace the older `roles/logging.viewer` + grant. Existing installations must apply the updated module and confirm + that the old grant is removed. See the full + [permissions guide](../docs/permissions.md) for the granted roles. - No write permissions. Nullify cannot modify your environment. - No long-lived secrets. Revoke at any time with `terraform destroy`. +The tenant claim condition rejects tokens for a different tenant, but it relies on Nullify's signing key. It does not protect against a compromised signing key that can forge tenant claims. + ## Deployment modes | Mode | `scope` value | What to set | Coverage | diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf index 5f45ecf..c006a48 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf @@ -6,16 +6,12 @@ # minting a per-tenant RS256 JWT with `tenant_id` as a custom claim. The # subject token is exchanged via Google STS for a short-lived federated # access token, then used to impersonate the service account this module -# creates. The pool's `attribute_condition` pins trust to the customer's -# specific Nullify tenant id, so even if Nullify's signing key were stolen -# an attacker could not mint a token accepted by another tenant's provider. +# creates. # # No long-lived secrets are minted by this module. The customer can revoke # access at any time by deleting the workload identity provider, the service # account, or both. # -# Permissions are intentionally read-only and limited to service-configuration -# and network-topology metadata. There are NO data-plane permissions: # - storage: bucket metadata only, never object data # - secret manager: secret metadata only, never secret payloads # - bigquery: schema/IAM only, never table rows @@ -86,9 +82,6 @@ locals { # KMS key rings + crypto keys. "roles/cloudkms.viewer", - # Logging sinks + exclusions (config only โ€” no log payload access). - "roles/logging.viewer", - # Cloud Run services + revisions. "roles/run.viewer", @@ -120,9 +113,11 @@ locals { # custom-role resources actually exists. custom_role_id = local.use_org_custom_role ? google_organization_iam_custom_role.nullify_cloud_connector[0].id : google_project_iam_custom_role.nullify_cloud_connector[0].id - # Permissions valid in both org- and project-scoped custom roles. Strict - # allowlist of *.get / *.list only โ€” no mutations and no data-plane reads. custom_role_permissions_common = [ + "logging.sinks.list", + "logging.logMetrics.list", + "logging.buckets.list", + # Cloud Armor security policies (ingress WAF rules). "compute.securityPolicies.get", "compute.securityPolicies.list", @@ -333,11 +328,6 @@ resource "google_iam_workload_identity_pool_provider" "nullify_oidc" { issuer_uri = var.nullify_oidc_issuer_uri } - # Pin trust to this specific Nullify tenant. Nullify's OIDC issuer is - # multi-tenant; the JWT carries a `tenant_id` custom claim. Without this - # condition any Nullify tenant could exchange a token against this - # provider. With it, even if Nullify's signing key were stolen, an - # attacker could not mint a token accepted by another tenant's provider. attribute_condition = "assertion.tenant_id == \"${var.nullify_tenant_id}\"" attribute_mapping = { diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf index 20417c7..0020b93 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf @@ -1,5 +1,5 @@ output "service_account_email" { - description = "Email address of the service account Nullify will impersonate. Paste this into the Nullify console under Settings -> Cloud Integrations -> GCP." + description = "Email address of the service account Nullify will impersonate. Paste this into the Nullify console under Settings -> Connectors -> GCP." value = google_service_account.nullify_cloud_connector.email } @@ -25,5 +25,5 @@ output "scope" { output "next_steps" { description = "Human-readable next steps. Surfaces in the apply output so customers don't have to dig through the Nullify docs." - value = "Paste the service_account_email and workload_identity_provider outputs into the Nullify console under Settings -> Cloud Integrations -> GCP, then click Verify." + value = "Paste the service_account_email and workload_identity_provider outputs into Settings -> Connectors -> GCP. Set the matching project, folder or organization scope, save the connection, then select Validate and check the first collected inventory." } diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/tests/logging_permissions.tftest.hcl b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/tests/logging_permissions.tftest.hcl new file mode 100644 index 0000000..8064a77 --- /dev/null +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/tests/logging_permissions.tftest.hcl @@ -0,0 +1,60 @@ +mock_provider "google" { + mock_data "google_project" { + defaults = { number = "123456789012" } + } +} + +variables { + customer_name = "marketplace-test" + host_project_id = "nullify-test" + nullify_oidc_issuer_uri = "https://gcp.nullify.ai" + nullify_tenant_id = "tenant-test" + project_ids = ["nullify-test"] +} + +run "project_logging_metadata_only" { + command = plan + variables { scope = "projects" } + + assert { + condition = alltrue([for grant in google_project_iam_member.predefined : grant.role != "roles/logging.viewer"]) + error_message = "Project installs must not grant log-entry access through Logs Viewer." + } + assert { + condition = toset([for permission in google_project_iam_custom_role.nullify_cloud_connector[0].permissions : permission if startswith(permission, "logging.")]) == toset(["logging.sinks.list", "logging.logMetrics.list", "logging.buckets.list"]) + error_message = "Logging collection must retain metadata listing without log-entry reads or writes." + } +} + +run "organization_logging_metadata_only" { + command = plan + variables { + scope = "organization" + organization_id = "123456789012" + } + assert { + condition = alltrue([for grant in google_organization_iam_member.predefined : grant.role != "roles/logging.viewer"]) + error_message = "Organization installs must not grant Logs Viewer." + } + assert { + condition = toset([for permission in google_organization_iam_custom_role.nullify_cloud_connector[0].permissions : permission if startswith(permission, "logging.")]) == toset(["logging.sinks.list", "logging.logMetrics.list", "logging.buckets.list"]) + error_message = "Organization custom roles must retain metadata listing without log-entry reads or writes." + } +} + +run "folder_logging_metadata_only" { + command = plan + variables { + scope = "folder" + organization_id = "123456789012" + folder_id = "987654321" + } + assert { + condition = alltrue([for grant in google_folder_iam_member.predefined : grant.role != "roles/logging.viewer"]) + error_message = "Folder installs must not grant Logs Viewer." + } + assert { + condition = length(google_organization_iam_custom_role.nullify_cloud_connector) == 1 && length(google_folder_iam_member.custom) == 1 + error_message = "Folder installs must retain the organization-defined custom role binding." + } +} diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf index f64da1e..fc797e9 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf @@ -51,7 +51,7 @@ variable "project_ids" { } variable "nullify_oidc_issuer_uri" { - description = "Nullify's OIDC issuer URL (e.g. https://gcp.nullify.ai for prod, https://gcp.dev.nullify.ai for dev). Provided in the Nullify console under Settings -> Cloud Integrations -> GCP. Google STS fetches the JWKS document from `{issuer}/.well-known/jwks.json` to verify subject token signatures." + description = "Nullify's OIDC issuer URL (e.g. https://gcp.nullify.ai for prod, https://gcp.dev.nullify.ai for dev). Provided in the Nullify console under Settings -> Connectors -> GCP. Google STS fetches the JWKS document from `{issuer}/.well-known/jwks.json` to verify subject token signatures." type = string validation { condition = startswith(var.nullify_oidc_issuer_uri, "https://") && !endswith(var.nullify_oidc_issuer_uri, "/") @@ -60,7 +60,7 @@ variable "nullify_oidc_issuer_uri" { } variable "nullify_tenant_id" { - description = "Your Nullify tenant id. Provided in the Nullify console under Settings -> Cloud Integrations -> GCP. Embedded in the WIF provider's attribute_condition so the pool only accepts subject tokens minted for THIS tenant; this is the per-tenant isolation that makes the integration safe in a multi-tenant Nullify deployment." + description = "Your Nullify tenant id. Provided in the Nullify console under Settings -> Connectors -> GCP. Embedded in the WIF provider's attribute_condition so the pool only accepts subject tokens minted for THIS tenant; this is the per-tenant isolation that makes the integration safe in a multi-tenant Nullify deployment." type = string validation { condition = length(var.nullify_tenant_id) > 0 && length(var.nullify_tenant_id) <= 100 && can(regex("^[A-Za-z0-9_-]+$", var.nullify_tenant_id)) diff --git a/gcp-integration-setup/terraform/modules/nullify-gke-collector/README.md b/gcp-integration-setup/terraform/modules/nullify-gke-collector/README.md index d10e23b..fe0d6c1 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gke-collector/README.md +++ b/gcp-integration-setup/terraform/modules/nullify-gke-collector/README.md @@ -13,37 +13,38 @@ The Nullify k8s-collector on GKE uses a projected Kubernetes ServiceAccount toke --format='value(selfLink)' ``` -2. Paste the URL into the Nullify console under **Settings โ†’ Cloud Integrations โ†’ GCP โ†’ GKE Clusters**. +2. Paste the URL into the Nullify console under **Settings โ†’ Connectors โ†’ GCP โ†’ GKE OIDC issuer URLs**. -3. Nullify returns the **role ARN**. Deploy the Helm chart: +3. Nullify returns the **role ARN**. For source chart 0.6.0, obtain the exact `collectorUploadPrefix` and confirm the paired backend rollout first. Copy the bucket, prefix, and KMS key from your deployment settings; the values below are placeholders. Leave the AWS region unset so the chart derives the upload region from the KMS key. Save the following as `values-gke.yaml`, replace every placeholder, then deploy the Helm chart: ```yaml cloudProvider: gcp collector: clusterName: "my-gke-cluster" - aws: - region: "us-east-1" s3: bucket: "your-nullify-bucket" + keyPrefix: "k8s-collector/YOUR-SOURCE-TENANT-ID/" + kms: + keyArn: "arn:aws:kms:ap-southeast-2:123456789012:key/11111111-1111-1111-1111-111111111111" gke: awsRoleArn: "arn:aws:iam::123456789012:role/..." # from Nullify ``` ```bash - helm install nullify-collector nullify/nullify-k8s-collector -f values-gke.yaml + helm install nullify-collector ./helm-charts/nullify-k8s-collector \ + --namespace nullify --create-namespace -f values-gke.yaml ``` **Prerequisites:** Any GKE cluster, K8s 1.22+. No GKE Workload Identity setup required. -## When to use this module +Run the source-chart command from a reviewed repository checkout after release compatibility is confirmed. For the published chart 0.5.0, use that release's values and instructions rather than substituting the collector 4 image. -This module creates a GCP service account with a Workload Identity binding. It is only needed if: +## Scope of this module -- Your cluster does not support projected service account tokens (K8s < 1.22, extremely rare on GKE) -- You need the GCP metadata endpoint auth path instead of the projected token path +This module creates a GCP service account and a Workload Identity binding. The standard collector chart uses a projected Kubernetes service-account token to assume its AWS role and does not require these GCP identity resources. -For all other cases, use the standard flow above. +Creating this module does not add a fallback authentication mode to that chart, configure AWS OIDC trust, or make a cluster without projected tokens supported. Use it only when Nullify confirms a separate deployment requirement for the GCP identity resources. ## What this module creates diff --git a/helm-charts/nullify-k8s-collector/Chart.yaml b/helm-charts/nullify-k8s-collector/Chart.yaml index 1ee933d..4afff4f 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.6.0 +version: 0.6.1 appVersion: "4.0.0" kubeVersion: ">=1.21.0-0" maintainers: diff --git a/helm-charts/nullify-k8s-collector/README.md b/helm-charts/nullify-k8s-collector/README.md index 2199cf3..de1c5d4 100644 --- a/helm-charts/nullify-k8s-collector/README.md +++ b/helm-charts/nullify-k8s-collector/README.md @@ -27,12 +27,14 @@ S3 bucket. No long-lived AWS credential is stored in the customer cluster. ## Installation +Chart 0.6.0 uses collector 4 and requires the exact `collectorUploadPrefix` from a compatible Nullify backend. Confirm the paired IAM/backend rollout before installing or upgrading. The standard Kubernetes panel on older deployments does not supply this value; contact Nullify instead of deriving a prefix. For an existing 3.x backend, use a complete compatible version set confirmed by Nullify, including chart 0.5.0 and its matching IAM policy. See the [cutover and rollback requirements](../../README.md#collector-4-source-prefix-cutover). + The chart does not create its namespace. Let Helm create it: ```bash helm repo add nullify https://nullify-platform.github.io/nullify-cloud-connector/ helm repo update -helm upgrade --install nullify-k8s-collector nullify/nullify-k8s-collector \ +helm upgrade --install nullify-k8s-collector nullify/nullify-k8s-collector --version 0.6.0 \ --namespace nullify --create-namespace \ -f values-production.yaml ``` @@ -46,9 +48,9 @@ The chart refuses to render when a required value is missing or still a placehol ## Configuration -> **Required values**: `collector.clusterName`, `collector.s3.bucket`, `collector.kms.keyArn`, and +> **Required values**: `collector.clusterName`, `collector.s3.bucket`, `collector.s3.keyPrefix`, `collector.kms.keyArn`, and > `serviceAccount.annotations."eks.amazonaws.com/role-arn"` (EKS) or `collector.gke.awsRoleArn` (GKE). -> Get these from the Nullify configure page. +> Get these from the Nullify deployment settings. If the exact prefix is absent, stop and ask Nullify to confirm backend compatibility. | Parameter | Description | Default | |-----------|-------------|---------| @@ -87,14 +89,14 @@ The collector container runs with: ## AWS IAM Configuration -> ๐Ÿ“– **Note**: The IAM role ARN, S3 bucket and KMS key ARN are provided in the Nullify configure page (Configure > Integrations > AWS). +> ๐Ÿ“– **Note**: Copy the IAM role ARN, S3 bucket and KMS key ARN from **Settings โ†’ Connectors โ†’ AWS** and **Kubernetes**. Obtain the exact collector upload prefix from a compatible Nullify deployment. ### EKS with IRSA (IAM Roles for Service Accounts) Do not create the IRSA role by hand. The Nullify AWS integration role gains the collector's trust and permissions when you enable its EKS integration: -- **CloudFormation** ([template](../../aws-integration-setup/cloudformation/README.md)): update the stack with `EnableEKSIntegration=true`, `EKSOidcProviderURL=oidc.eks..amazonaws.com/id/` (no `https://`), `NullifyS3Bucket` and `NullifyKMSKeyArn` (the key ARN). -- **Terraform** ([module](../../aws-integration-setup/terraform/README.md)): set `enable_kubernetes_integration = true`, `eks_cluster_arns`, `s3_bucket_name` and `kms_key_arn`. `kubernetes_namespace` and `service_account_name` must match this chart's `serviceAccount.namespace` and `serviceAccount.name`. Deploy the collector with either this chart or the `k8s-resources` module, not both. +- **CloudFormation** ([template](../../aws-integration-setup/cloudformation/README.md)): update the stack with `EnableEKSIntegration=true`, `EKSOidcProviderURL=oidc.eks..amazonaws.com/id/` (no `https://`), `NullifyS3Bucket`, `NullifyKMSKeyArn` (the key ARN), and the exact `CollectorUploadPrefix`. +- **Terraform** ([module](../../aws-integration-setup/terraform/README.md)): set `enable_kubernetes_integration = true`, `eks_cluster_arns`, `s3_bucket_name`, `kms_key_arn`, and `collector_upload_prefix`. `kubernetes_namespace` and `service_account_name` must match this chart's `serviceAccount.namespace` and `serviceAccount.name`. Deploy the collector with either this chart or the `k8s-resources` module, not both. The cluster needs an IAM OIDC provider (`eksctl utils associate-iam-oidc-provider --cluster --approve`). @@ -121,9 +123,9 @@ serviceAccount: For GKE clusters, the collector authenticates to AWS without any long-lived credential. The flow is: -1. GKE projects a Google-signed ServiceAccount token into the collector pod. +1. Kubernetes projects a ServiceAccount token signed by the GKE cluster into the collector pod. 2. The collector forwards that token to AWS STS `AssumeRoleWithWebIdentity`. -3. AWS validates the token against the Google OIDC provider and returns short-lived +3. AWS validates the token against the registered cluster OIDC provider and returns short-lived credentials for a Nullify-owned IAM role scoped to your S3 prefix. #### One-time onboarding @@ -146,6 +148,8 @@ The chart uses a projected Kubernetes ServiceAccount token signed by the cluster #### Helm values +Save the following as `values-gke.yaml` and replace all placeholders, including the exact prefix returned by Nullify. Do not use this example until the collector 4 backend and role policy are ready. + ```yaml cloudProvider: gcp @@ -153,6 +157,7 @@ collector: clusterName: "my-gke-cluster" s3: bucket: "your-nullify-bucket" + keyPrefix: "k8s-collector/YOUR-SOURCE-TENANT-ID/" kms: keyArn: "arn:aws:kms:us-east-1:123456789012:key/your-key-id" gke: @@ -163,7 +168,7 @@ collector: Then install: ```bash -helm upgrade --install nullify-k8s-collector nullify/nullify-k8s-collector \ +helm upgrade --install nullify-k8s-collector nullify/nullify-k8s-collector --version 0.6.0 \ --namespace nullify --create-namespace -f values-gke.yaml ``` @@ -233,9 +238,9 @@ To enable debug logging for troubleshooting: enabled: true ``` -2. **Upgrade the deployment**: +2. **Upgrade the deployment** with the same chart version and complete values used for installation. The command below is for a collector 4 deployment that has completed the cutover: ```bash - helm upgrade nullify-k8s-collector nullify/nullify-k8s-collector -n nullify -f values.yaml + helm upgrade nullify-k8s-collector nullify/nullify-k8s-collector --version 0.6.0 -n nullify -f values.yaml ``` 3. **Check debug logs**: diff --git a/scripts/publish-helm-repo.sh b/scripts/publish-helm-repo.sh index 8dce1b4..12466e9 100755 --- a/scripts/publish-helm-repo.sh +++ b/scripts/publish-helm-repo.sh @@ -493,6 +493,9 @@ for pkg in "$new_dir"/*.tgz; do cp "$pkg" "$SITE_DIR/" done cp "$new_dir/index.yaml" "$SITE_DIR/index.yaml" +if [ -f artifacthub-repo.yml ]; then + cp artifacthub-repo.yml "$SITE_DIR/artifacthub-repo.yml" +fi while IFS= read -r url; do [ -f "$SITE_DIR/$(basename "$url")" ] || die "index.yaml lists $url but the site has no such file" diff --git a/scripts/test_gcp_installer.py b/scripts/test_gcp_installer.py new file mode 100644 index 0000000..817e89a --- /dev/null +++ b/scripts/test_gcp_installer.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class GCPInstaller(unittest.TestCase): + def test_gcloud_commands_match_organization_terraform_permissions(self): + terraform = (ROOT / 'gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf').read_text() + def values(name): + block = re.search(r'\b' + name + r'\s*=\s*\[(.*?)\]', terraform, re.S).group(1) + return set(re.findall(r'"([A-Za-z][A-Za-z0-9./]+)"', block)) + permissions = values('custom_role_permissions_common') | values('custom_role_permissions_org_only') + roles = values('predefined_viewer_roles') | values('discovery_roles') + for exists in (False, True): + with self.subTest(existing_custom_role=exists), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + log = root / 'commands.jsonl' + gcloud = root / 'gcloud' + gcloud.write_text('''#!/usr/bin/env python3 +import json, os, sys +with open(os.environ['GCLOUD_LOG'], 'a') as log: + log.write(json.dumps(sys.argv[1:]) + '\\n') +if sys.argv[1:4] == ['iam', 'roles', 'describe']: + sys.exit(0 if os.environ['ROLE_EXISTS'] == '1' else 1) +if sys.argv[1:4] == ['iam', 'workload-identity-pools', 'describe']: + print('projects/123/locations/global/workloadIdentityPools/nullify-cloud-connector') +if sys.argv[1:3] == ['projects', 'describe']: + print('123') +''') + gcloud.chmod(0o700) + environment = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', GCLOUD_LOG=str(log), ROLE_EXISTS=str(int(exists)), NULLIFY_HOST_PROJECT='host-project', NULLIFY_ORG_ID='123', NULLIFY_OIDC_ISSUER_URI='https://example.com', NULLIFY_TENANT_ID='tenant') + result = subprocess.run(['bash', str(ROOT / 'gcp-integration-setup/scripts/install.sh')], env=environment, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + commands = [json.loads(line) for line in log.read_text().splitlines()] + custom = next(command for command in commands if command[:3] == ['iam', 'roles', 'update' if exists else 'create']) + granted_permissions = next(arg.split('=', 1)[1] for arg in custom if arg.startswith('--permissions=')).split(',') + self.assertEqual(set(granted_permissions), permissions) + granted_roles = {arg.split('=', 1)[1] for command in commands if command[:2] == ['organizations', 'add-iam-policy-binding'] for arg in command if arg.startswith('--role=')} + self.assertEqual(granted_roles, roles | {'organizations/123/roles/nullifyCloudConnector'}) + self.assertNotIn('roles/logging.viewer', granted_roles) + self.assertIn('Save the connection, then select Validate', result.stdout) + log.write_text('') + removed = subprocess.run(['bash', str(ROOT / 'gcp-integration-setup/scripts/uninstall.sh')], env=environment, capture_output=True, text=True, timeout=10) + self.assertEqual(removed.returncode, 0, removed.stderr) + commands = [json.loads(line) for line in log.read_text().splitlines()] + removed_roles = {arg.split('=', 1)[1] for command in commands if command[:2] == ['organizations', 'remove-iam-policy-binding'] for arg in command if arg.startswith('--role=')} + self.assertEqual(removed_roles, granted_roles | {'roles/logging.viewer'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_verify_published_helm_repo.py b/scripts/test_verify_published_helm_repo.py new file mode 100644 index 0000000..bd2535f --- /dev/null +++ b/scripts/test_verify_published_helm_repo.py @@ -0,0 +1,89 @@ +import functools +import hashlib +import http.server +import importlib.util +import json +import subprocess +import tempfile +import threading +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location("verify_public_helm", Path(__file__).with_name("verify-published-helm-repo.py")) +verify_public = importlib.util.module_from_spec(spec) +spec.loader.exec_module(verify_public) + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + +class PublicationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + chart = self.root / "example" + chart.mkdir() + (chart / "Chart.yaml").write_text("apiVersion: v2\nname: example\nversion: 1.0.0\n") + subprocess.run(["helm", "package", str(chart), "--destination", str(self.root)], check=True, capture_output=True) + self.package = self.root / "example-1.0.0.tgz" + digest = hashlib.sha256(self.package.read_bytes()).hexdigest() + self.expected = {"entries": {"example": [{"version": "1.0.0", "digest": digest, "urls": [self.package.name]}]}} + self.write_index(self.expected) + handler = functools.partial(QuietHandler, directory=str(self.root)) + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.url = f"http://127.0.0.1:{self.server.server_port}/" + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + self.temporary.cleanup() + + def write_index(self, data): + (self.root / "index.yaml").write_text(json.dumps(data)) + + def test_public_consumer_can_download_and_read_verified_chart(self): + self.assertEqual(verify_public.verify(self.expected, self.url), 1) + + def test_missing_version_fails(self): + self.write_index({"entries": {}}) + with self.assertRaisesRegex(ValueError, "public index does not match"): + verify_public.verify(self.expected, self.url) + + def test_package_tampering_fails(self): + self.package.write_bytes(b"tampered") + with self.assertRaisesRegex(ValueError, "digest mismatch"): + verify_public.verify(self.expected, self.url) + + def test_outside_repository_download_is_refused(self): + changed = json.loads(json.dumps(self.expected)) + changed["entries"]["example"][0]["urls"] = ["https://example.com/other.tgz"] + self.write_index(changed) + with self.assertRaisesRegex(ValueError, "does not match the expected location"): + verify_public.verify(self.expected, self.url) + + def test_empty_expected_index_cannot_pass(self): + with self.assertRaisesRegex(ValueError, "no charts"): + verify_public.verify({"entries": {}}, self.url) + + def test_public_repository_ownership_metadata_is_verified(self): + metadata = {"repositoryID": "158135dc-d5cd-4016-9d22-5494d9510594"} + (self.root / "artifacthub-repo.yml").write_text(json.dumps(metadata)) + self.assertEqual(verify_public.verify(self.expected, self.url, metadata), 1) + + def test_changed_repository_ownership_metadata_fails(self): + (self.root / "artifacthub-repo.yml").write_text('{"repositoryID":"another-repository"}') + with self.assertRaisesRegex(ValueError, "ownership metadata does not match"): + verify_public.verify(self.expected, self.url, {"repositoryID": "expected-repository"}) + + def test_missing_repository_ownership_metadata_fails(self): + with self.assertRaises(OSError): + verify_public.verify(self.expected, self.url, {"repositoryID": "expected-repository"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-published-helm-repo.py b/scripts/verify-published-helm-repo.py new file mode 100644 index 0000000..122ba85 --- /dev/null +++ b/scripts/verify-published-helm-repo.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +import subprocess +import tempfile +import time +import urllib.parse +import urllib.request +from pathlib import Path + + +def yaml_data(contents): + parsed = subprocess.run(["yq", "-o=json", "."], input=contents, capture_output=True, check=True) + return json.loads(parsed.stdout) + + +def fetch(url): + with urllib.request.urlopen(url, timeout=30) as response: + return response.read() + + +def verify(expected, repository_url, expected_metadata=None): + base = repository_url.rstrip("/") + "/" + if expected_metadata is not None: + if not expected_metadata.get("repositoryID"): + raise ValueError("expected repository metadata has no repositoryID") + metadata = yaml_data(fetch(base + "artifacthub-repo.yml")) + if metadata != expected_metadata: + raise ValueError("public repository ownership metadata does not match") + public = yaml_data(fetch(base + "index.yaml")) + checked = 0 + with tempfile.TemporaryDirectory(prefix="nullify-public-helm-") as temporary: + for name, versions in expected["entries"].items(): + actual_versions = {entry["version"]: entry for entry in public.get("entries", {}).get(name, [])} + for entry in versions: + version = entry["version"] + actual = actual_versions.get(version) + if actual is None or actual.get("digest") != entry["digest"]: + raise ValueError(f"public index does not match {name} {version}") + package_url = urllib.parse.urljoin(base, actual["urls"][0]) + expected_urls = {urllib.parse.urljoin(base, url) for url in entry["urls"]} + if package_url not in expected_urls or urllib.parse.urlsplit(package_url).scheme not in {"https", "http"}: + raise ValueError(f"package URL does not match the expected location for {name} {version}") + contents = fetch(package_url) + if hashlib.sha256(contents).hexdigest() != entry["digest"]: + raise ValueError(f"public package digest mismatch for {name} {version}") + path = Path(temporary) / f"{name}-{version}.tgz" + path.write_bytes(contents) + metadata = subprocess.run(["helm", "show", "chart", str(path)], check=True, capture_output=True).stdout + chart = yaml_data(metadata) + if chart.get("name") != name or str(chart.get("version")) != str(version): + raise ValueError(f"downloaded chart identity mismatch for {name} {version}") + print(f"verified {name} {version}", flush=True) + checked += 1 + if checked == 0: + raise ValueError("expected index contains no charts") + return checked + + +def main(): + parser = argparse.ArgumentParser(description="Verify public chart index, immutable digests and downloaded package metadata.") + parser.add_argument("--expected-index", type=Path, required=True) + parser.add_argument("--expected-repository-metadata", type=Path) + parser.add_argument("--url", default="https://nullify-platform.github.io/nullify-cloud-connector/") + parser.add_argument("--attempts", type=int, default=1) + parser.add_argument("--retry-seconds", type=float, default=15) + args = parser.parse_args() + if args.attempts < 1 or args.retry_seconds < 0: + parser.error("attempts must be positive and retry-seconds must be non-negative") + expected = yaml_data(args.expected_index.read_bytes()) + expected_metadata = yaml_data(args.expected_repository_metadata.read_bytes()) if args.expected_repository_metadata else None + for attempt in range(args.attempts): + try: + count = verify(expected, args.url, expected_metadata) + print(f"Public Helm verification passed for {count} chart versions") + return + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + if attempt + 1 == args.attempts: + raise SystemExit(str(error)) from error + print(f"Public Helm verification attempt {attempt + 1} failed: {error}; retrying", flush=True) + time.sleep(args.retry_seconds) + + +if __name__ == "__main__": + main()