Skip to content

kaveo · Guides

Connect cloud accounts

Connect AWS through a read-only cross-account role with an ExternalId, then optionally Azure, GCP, Kubernetes and GitHub, and run your first real scan.

On this page

kaveo scans with read-only access that you grant. For AWS, the worker assumes a read-only IAM role that trusts only kaveo's identity and requires an ExternalId, so kaveo holds no access keys for your account. For Azure, GCP, Kubernetes and GitHub, kaveo stores a reference to a read-only credential that stays in your deployment's environment.

Start from a running stack (see Quick start). Registering and verifying accounts, setting schedules and the organizations helper need manage_accounts, which only admins have. API tokens default to view_findings and run_scan, so mint an onboarding token with kaveo auth token create --name onboarding --scope manage_accounts --scope run_scan --scope view_findings.

AWS#

AWS support is built into the default image. It has 44 read-only collectors and 93 detectors.

1. Give kaveo base credentials#

The worker assumes your role from kaveo's own AWS identity. Run kaveo on EC2 or ECS to use the instance or task role. Otherwise, set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and, for temporary credentials, AWS_SESSION_TOKEN in .env. Compose passes them to the api and worker. For the IMDSv2 hop limit on EC2 and for named profiles, see Deployment.

VariableDefaultEffect
KAVEO_SCAN_MODEautoauto falls back to the labeled synthetic fixture only when kaveo has no base credentials. aws collects real data or fails. synthetic never calls AWS.
KAVEO_PRINCIPAL_ARNemptykaveo's identity ARN, the principal each role trusts. The console, the policy helper and the StackSet template pre-fill it. Without it, the console shows a placeholder and won't copy its setup command.
KAVEO_CFN_TEMPLATE_URLemptyOptional. The S3 URL of a copy of infra/aws/kaveo-readonly-role.cfn.yaml. It turns on the console's Launch stack in AWS button, which opens a prefilled CloudFormation quick-create page.

In production, set KAVEO_SCAN_MODE=aws. A missing credential then fails the scan instead of producing demo data. To check which ARN kaveo runs as:

bash
docker compose --env-file .env -f infra/docker-compose.yml exec worker \
  python -c "import boto3; print(boto3.client('sts').get_caller_identity()['Arn'])"

If you get an assumed-role session ARN back, use the role's IAM ARN as the principal instead: arn:aws:iam::<account-id>:role/<role-name>.

2. Deploy the read-only role#

Deploy the role in each account you want to scan. The role kaveo-readonly trusts only the principal you pass, requires a matching sts:ExternalId and attaches the AWS-managed read-only policies SecurityAudit and job-function/ViewOnlyAccess. Sessions last at most one hour and are named kaveo-scan in CloudTrail. Use an ExternalId of at least 8 characters, such as the output of openssl rand -hex 16.

bash
aws cloudformation deploy \
  --template-file infra/aws/kaveo-readonly-role.cfn.yaml \
  --stack-name kaveo-readonly \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
      KaveoPrincipalArn=arn:aws:iam::<kaveo-account-id>:role/<kaveo-identity> \
      ExternalId=<a-strong-shared-secret>

aws cloudformation describe-stacks --stack-name kaveo-readonly \
  --query "Stacks[0].Outputs" --output table

The RoleArn output is the ARN you register. To use Terraform instead, apply the file from its own directory. infra/aws also holds the remediation write-role module, and that module declares the same variables:

bash
mkdir kaveo-readonly && cp infra/aws/kaveo-readonly-role.tf kaveo-readonly/
cd kaveo-readonly
terraform init
terraform apply \
  -var 'kaveo_principal_arn=arn:aws:iam::<kaveo-account-id>:role/<kaveo-identity>' \
  -var 'external_id=<a-strong-shared-secret>'

Its output is role_arn. The console's Connect account form can also create this role for you with a fresh ExternalId. With KAVEO_CFN_TEMPLATE_URL set, it shows Launch stack in AWS. Otherwise, it gives you one AWS CLI command to run in CloudShell. Under Advanced, it also shows the role as CloudFormation, Terraform, AWS CLI, CDK or JSON.

3. Register the account#

In the console, open Connect account and enter the 12-digit account id. kaveo derives the role ARN arn:aws:iam::<account-id>:role/kaveo-readonly. If you deployed the role yourself, enter your ARN and ExternalId under Advanced. The console registers the account, then checks that kaveo can assume the role, and shows the AWS error if it can't. The account stays registered when that check fails. Connecting it again updates the same record.

From the API:

bash
curl -sX POST https://<host>/v1/accounts \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "provider": "aws",
    "name": "prod",
    "aws_account_id": "123456789012",
    "role_arn": "arn:aws:iam::123456789012:role/kaveo-readonly",
    "external_id": "<external-id>"
  }'

curl -sX POST https://<host>/v1/accounts/<account-uuid>/verify \
  -H "Authorization: Bearer $TOKEN"

<account-uuid> is the id in the register response, not the 12-digit AWS account number. kaveo accounts list shows it too.

Verify assumes the role and returns verified. If verification fails, the response also has an error field. With KAVEO_SCAN_MODE=synthetic, verify succeeds without calling AWS. The first successful verify of an account with no scans queues a scan and returns its scan_id.

If you register an AWS account id that already exists, kaveo replaces its role ARN, ExternalId, name and write-role fields with what you send. A field you leave out is cleared, so resend write_role_arn and write_external_id if you use them. Those two optional fields set up the separate role that approved fixes use. See Remediation and autonomous patrol.

Two helpers are available:

  • POST /v1/onboarding/policy asks the configured AI provider to draft the role from your external_id and a plain-English description, in the format you choose: cloudformation, terraform, cli or json (the default). Deterministic code then checks that the draft is read-only and keeps the ExternalId condition. If the check fails, which it always does with the offline provider, you get kaveo's standard read-only role as JSON instead, and notes says which one you got. If the provider call itself errors, the endpoint returns 503.
  • GET /v1/onboarding/organizations returns a StackSet template for a read-only role named KaveoReadOnlyRole, which attaches ReadOnlyAccess and SecurityAudit and requires your ExternalId. Deploy it from the management account as a service-managed StackSet with automatic deployment on. Then register each member account with its KaveoReadOnlyRole ARN and the same ExternalId, under Advanced in the console. If the first AWS account you connected can list the organization, as the management account or a delegated administrator can, the response also flags member accounts that aren't connected yet.

4. Run a scan#

Press Run scan on the dashboard, or call the API:

bash
curl -sX POST https://<host>/v1/scans \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"account_id": "<account-uuid>", "regions": ["us-east-1"]}'

To scan every enabled region, leave out regions. With the CLI, point it at your stack with --url https://<host> or KAVEO_API_URL, then run:

bash
kaveo scan start --account <account-uuid> --wait

Repeat --region to limit regions. Add --fail-on high to exit with code 1 when a finding is at or above that severity. When a scan finishes, its coverage.mode is aws if the data came from real collection and synthetic if it came from the fixture.

5. Schedule scans#

bash
curl -sX PUT https://<host>/v1/accounts/<account-uuid>/schedule \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"interval_hours": 24}'

The interval can be 1 to 720 hours. null turns scheduling off. The first scheduled scan runs one interval after you set it. The dashboard's Auto-scan control sets the same value, with Off, every 6 hours, daily and weekly options.

Azure, GCP, Kubernetes and GitHub#

An account for one of these providers stores the provider's native id and a credential_ref of the form env:NAME. env: is the only scheme kaveo resolves today. At scan time the worker reads environment variable NAME and parses it as a JSON object. kaveo stores the reference, never the secret.

ProviderExtranative_account_idCredential JSONRead-only principal
azurekaveo[azure]Subscription id{tenant_id, client_id, client_secret}Service principal with Reader and Security Reader
gcpkaveo[gcp]Project idService-account key JSONService account with roles/viewer and roles/iam.securityReviewer
k8skaveo[k8s]Cluster name{host, token, ssl_ca_cert?, verify_ssl?}ServiceAccount bound to a read-only ClusterRole that can list clusterrolebindings
saaskaveo[saas]Org login{token}GitHub token with the read:org scope

For Kubernetes, ssl_ca_cert is a file path, so the CA file must exist inside the worker container. verify_ssl applies only when you don't provide ssl_ca_cert. It defaults to true, and you should keep it on outside a lab.

For GitHub, the collector reads every organization the token can see, not only the one you register. If the token can't see an organization's 2FA setting, kaveo raises no finding for it.

To install an extra, add it to the package install in the api/worker Dockerfile (for example kaveo[azure,gcp]), then run docker compose --env-file .env -f infra/docker-compose.yml build api worker.

Compose passes only a fixed list of variables to the api and worker. Only the worker reads the credential, so add its variable to the worker in infra/docker-compose.override.yml:

yaml
services:
  worker:
    environment:
      KAVEO_AZURE_PROD: ${KAVEO_AZURE_PROD:?set KAVEO_AZURE_PROD in .env}

In .env, wrap the value in single quotes so that Compose keeps the JSON exactly as written. For GCP, first flatten the key file onto one line, for example with jq -c . key.json.

text
KAVEO_AZURE_PROD='{"tenant_id":"<tenant-id>","client_id":"<app-id>","client_secret":"<secret>"}'

Start the stack with both compose files, then register the account:

bash
docker compose --env-file .env \
  -f infra/docker-compose.yml -f infra/docker-compose.override.yml up -d

curl -sX POST https://<host>/v1/accounts \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "provider": "azure",
    "name": "azure-prod",
    "native_account_id": "<subscription-id>",
    "credential_ref": "env:KAVEO_AZURE_PROD"
  }'

In the console, choose the provider on Connect account and fill in Credential reference. Verify applies only to AWS, so start a scan the same way as for AWS. A live scan reports the provider id, such as azure, as its coverage.mode. The shipped detectors:

  • azure.storage_public_access (high): a storage account allows public blob access
  • gcp.bucket_public (critical): a bucket's IAM policy grants a role to allUsers or allAuthenticatedUsers
  • k8s.cluster_admin_binding (critical): cluster-admin is bound to a broad or anonymous group
  • saas.github_mfa_not_enforced (high): the organization does not require 2FA

If the extra is missing, the credential doesn't resolve or the provider API errors, the scan fails with the reason in coverage.error. For these providers, kaveo never substitutes demo data for a real account. Only KAVEO_SCAN_MODE=synthetic gives you the per-provider fixture, labeled synthetic.

Troubleshooting#

SymptomCause and fix
Coverage mode is synthetickaveo has no base AWS credentials and the mode is auto, the mode is synthetic, or you scanned the console's demo account. Add credentials and set KAVEO_SCAN_MODE=aws.
Verify returns verified: falseCheck the role ARN, that the trust policy names kaveo's principal, and that the ExternalId matches. error has the AWS message.
An AWS scan failsThe role is missing or AWS denied the assume. This fails the scan in both auto and aws modes. So do missing base credentials in aws mode. See coverage.error.
A non-AWS scan failsThe extra is missing, the env:NAME variable is unset, isn't valid JSON or lacks a required field, or the provider API returned an error. See coverage.error.
402 on registerThe workspace's plan is at its account limit. Registering an account that already exists doesn't count against it.
403 on register, verify or scheduleThe caller lacks manage_accounts.
409 on registerAnother workspace already registered this account.
Unsure the worker has an identityRun the get-caller-identity command from step 1. If it can't locate credentials, none reached the container.

Next steps#