Automated vulnerability scanner for Azure Container Registries, Azure DevOps repos, GCP Container Registries, and Kubernetes clusters. Auto-discovers ACRs via Azure Resource Manager, GCR images across GCP projects, clones repos from ADO, queries K8s clusters for running images — scans everything with Trivy and posts results to Slack.
Each Azure subscription, GCP project, or Kubernetes cluster gets its own CronJob, reports, and Slack channel.
- What it scans
- How it fits into the wider picture
- Running locally (macOS)
- Config
- Slack setup
- Deployment
- One-Time Setup
- EKS Deployment — Azure subscriptions (no AKS)
- EKS Deployment — GCP projects (no GKE)
- AKS Deployment (target has its own cluster)
- Deployment comparison
- Docker image tags
- Updating secrets
- Suspending and resuming a CronJob
- Removing a target
- Resources used on the cluster
- Rebuilding the Docker image
- Troubleshooting
| Mode | Flag | What it does |
|---|---|---|
| Registries | --registries-only |
Discovers all ACRs in an Azure subscription, scans every image |
| ADO Repos | --repos-only |
Discovers all repos in an ADO org, clones each, runs trivy fs |
| K8s Cluster | --cluster-only |
Queries a cluster for running images, scans only those |
| GCR | --gcr-only |
Discovers all images across GCP projects, scans those with a latest tag |
Each mode generates an HTML report and posts a Slack digest with severity counts, top CVEs, and a downloadable zip of the full report.
Without flags, it scans registries then prompts for repos and cluster.
| Layer | Tool | What it catches |
|---|---|---|
| Codebase | trivy fs . in pipeline |
Vulnerable dependencies, secrets, IaC misconfigs |
| Image (pre-push) | trivy image in pipeline |
Vulnerable OS packages + deps in built image |
| Registry | This tool (--registries-only / --gcr-only) |
Everything sitting in ACR/GCR (safety net) |
| Source repos | This tool (--repos-only) |
Dependencies across all ADO repos |
| Running workloads | This tool (--cluster-only) |
Vulns in images actually deployed |
brew install trivyFor GCR scanning, also install the Google Cloud SDK:
brew install --cask google-cloud-sdkcd vulnerability-scanner
pip install -r requirements.txt
cp config.yaml.example config.yamlDocker Desktop needs to be running.
az login
for acr in $(az acr list --query "[].name" -o tsv --subscription "<subscription-id>"); do
az acr login --name $acr
done
python scan.py --subscription "<subscription-id>"The subscription ID is in the output of az login, or:
az account show --query id -o tsvgcloud auth login
gcloud auth configure-docker eu.gcr.io
python scan.py --gcr-only# discover registries only
python scan.py --discover-only
# dry run — list everything, don't scan
python scan.py --dry-run
# registries only (no prompt for repos/cluster after)
python scan.py --registries-only --subscription "<sub-id>"
# ADO repos only
python scan.py --repos-only
# K8s cluster only
python scan.py --cluster-only
# specific kubectl context
python scan.py --cluster-only --context my-aks-cluster
# GCR only
python scan.py --gcr-onlyopen ./reports/<registry-name>/vuln_report_<timestamp>.html
open ./reports/repos/repo_scan_<timestamp>.html
open ./reports/cluster/cluster_scan_<timestamp>.htmlacr:
exclude_registries: [] # skip specific ACRs by name
exclude_repos: [] # skip repos inside any registry
tag_pattern: "^(latest|v?\\d+\\.\\d+\\.\\d+.*)$"
max_tags_per_repo: 3 # newest N per repo
trivy:
severities: ["CRITICAL", "HIGH"]
timeout: 300
slack:
webhook_url: "" # or SLACK_WEBHOOK_URL env var
channel: "#security-alerts"
bot_token: "" # xoxb-... for file uploads (or SLACK_BOT_TOKEN)
channel_id: "" # for file uploads (or SLACK_CHANNEL_ID)
enforcement:
fail_on: ["CRITICAL", "HIGH"]
enabled: true # false = report only, won't exit 1
gcr:
registry: "eu.gcr.io" # or us.gcr.io, asia.gcr.io
org_name: "gcr" # display name for reports and Slack
exclude_projects: [] # skip entire GCP projects
exclude_repos: # skip repos containing these strings
- appengine
- app-engine-tmpThe exclude_repos under gcr does partial matching — any repo path containing the string will be skipped. Useful for filtering out auto-generated App Engine, Cloud Functions (gcf), or other images you don't control.
- Create a Slack app at https://api.slack.com/apps
- OAuth & Permissions → add scopes:
files:write,chat:write - Install to workspace
- Copy the Bot User OAuth Token (
xoxb-...) - Create an Incoming Webhook for the target channel
- Invite the bot to the channel:
/invite @YourBotName - Get the channel ID: right-click channel → View channel details → scroll to bottom
Three CronJob templates are included:
| Template | Use case |
|---|---|
cronjob-azure-eks.yaml.example |
Azure subscription without AKS — runs on a central EKS/K8s cluster |
cronjob-aks.yaml.example |
Target has its own AKS — runs on that cluster |
cronjob-gcr-eks.yaml.example |
GCP projects without GKE — runs on a central EKS/K8s cluster |
To use: copy the template, rename to <name>.cronjob.yaml, and replace all <client> placeholders. Copies of the templates (.cronjob.yaml files) are gitignored.
These steps only need to be done once, regardless of how many targets you add.
az group create --name <resource-group> --location <region>
az acr create --resource-group <resource-group> --name <registry-name> --sku Basicaz acr login --name <registry-name>
docker build --platform linux/amd64 -t <registry-name>.azurecr.io/vulnerability-scanner:latest .
docker push <registry-name>.azurecr.io/vulnerability-scanner:latestAlways build with --platform linux/amd64 if you're on Apple Silicon.
This allows your clusters to pull the scanner image from the registry.
az ad sp create-for-rbac \
--name "acr-pull" \
--role AcrPull \
--scopes $(az acr show --name <registry-name> --query id -o tsv)Save the appId and password — you'll use them in every cluster as an image pull secret.
kubectl create namespace trivy-scannerkubectl create secret docker-registry acr-pull \
--namespace trivy-scanner \
--docker-server=<registry-name>.azurecr.io \
--docker-username="<appId from step 3>" \
--docker-password="<password from step 3>"Update the imagePullSecrets name in the CronJob templates if you use a different secret name.
Scans ACR registries and ADO repos in an Azure subscription remotely from a central cluster.
az login --tenant <target-tenant-id>
az ad sp create-for-rbac \
--name "trivy-scanner-<name>" \
--role Reader \
--scopes /subscriptions/<target-sub-id>Save the appId, password, and tenant.
Add AcrPull so it can pull images for scanning:
az role assignment create \
--assignee <appId> \
--role AcrPull \
--scope /subscriptions/<target-sub-id>Go to https://dev.azure.com/<org>/_usersSettings/tokens and create a PAT with:
- Code (Read)
- Build (Read)
kubectl create secret generic <name>-azure-creds \
--namespace trivy-scanner \
--from-literal=client-id="<appId>" \
--from-literal=client-secret="<password>" \
--from-literal=tenant-id="<tenant>" \
--from-literal=subscription-id="<target-sub-id>"
kubectl create secret generic <name>-ado-creds \
--namespace trivy-scanner \
--from-literal=org-url="https://dev.azure.com/<org>" \
--from-literal=pat="<pat>"
kubectl create secret generic <name>-slack \
--namespace trivy-scanner \
--from-literal=webhook-url="https://hooks.slack.com/services/..." \
--from-literal=bot-token="xoxb-..." \
--from-literal=channel-id="<channel-id>"cp cronjob-azure-eks.yaml.example <name>.cronjob.yamlReplace all <client> placeholders with <name>. Adjust the schedule.
kubectl apply -f <name>.cronjob.yaml
kubectl create job --from=cronjob/<name>-acr-scan test-scan -n trivy-scanner
kubectl logs -f -n trivy-scanner job/test-scanCheck Slack. Clean up:
kubectl delete job test-scan -n trivy-scannerScans GCR images across GCP projects remotely from a central cluster.
Create a service account in one project with Viewer and Artifact Registry Reader roles, then grant it access to the other projects via IAM.
for project in <project-1> <project-2> <project-3>; do
gcloud projects add-iam-policy-binding $project \
--member="serviceAccount:<sa-name>@<home-project>.iam.gserviceaccount.com" \
--role="roles/viewer"
gcloud projects add-iam-policy-binding $project \
--member="serviceAccount:<sa-name>@<home-project>.iam.gserviceaccount.com" \
--role="roles/artifactregistry.reader"
doneTo verify roles on a specific project:
gcloud projects get-iam-policy <project-id> \
--flatten="bindings[].members" \
--filter="bindings.members:<sa-email>" \
--format="table(bindings.role)"Create a JSON key: GCP Console → IAM & Admin → Service Accounts → Keys → Add Key → JSON.
kubectl create secret generic <name>-gcp-creds \
--namespace trivy-scanner \
--from-file=key.json=<path-to-key.json>
kubectl create secret generic <name>-slack \
--namespace trivy-scanner \
--from-literal=webhook-url="https://hooks.slack.com/services/..." \
--from-literal=bot-token="xoxb-..." \
--from-literal=channel-id="<channel-id>"cp cronjob-gcr-eks.yaml.example <name>.cronjob.yamlReplace all <client> placeholders. Adjust the schedule.
Update config.yaml.example with any project or repo exclusions under the gcr: section before rebuilding the Docker image (the config is baked in at build time).
kubectl apply -f <name>.cronjob.yaml
kubectl create job --from=cronjob/<name>-gcr-scan test-scan -n trivy-scanner
kubectl logs -f -n trivy-scanner job/test-scan -c scannerTo check the GCP auth init container:
kubectl logs -n trivy-scanner job/test-scan -c gcp-loginClean up:
kubectl delete job test-scan -n trivy-scannerScans only images running on the cluster. No cloud credentials needed — it runs inside the cluster.
Connect to the cluster first:
az login --tenant <tenant-id>
az aks get-credentials --resource-group <rg> --name <aks-name>You need Azure Kubernetes Service RBAC Reader (or higher) on the AKS resource. Owner on the Azure resource is not enough — AKS has separate Kubernetes RBAC.
kubectl create namespace trivy-scannerkubectl create secret docker-registry acr-pull \
--namespace trivy-scanner \
--docker-server=<registry-name>.azurecr.io \
--docker-username="<appId>" \
--docker-password="<password>"kubectl create secret generic <name>-slack \
--namespace trivy-scanner \
--from-literal=webhook-url="https://hooks.slack.com/services/..." \
--from-literal=bot-token="xoxb-..." \
--from-literal=channel-id="<channel-id>"kubectl create serviceaccount trivy-reader -n trivy-scanner
kubectl create clusterrolebinding trivy-reader-binding \
--clusterrole=view \
--serviceaccount=trivy-scanner:trivy-readercp cronjob-aks.yaml.example <name>.cronjob.yamlReplace all <client> placeholders. Set CLUSTER_NAME to whatever you want in the report and Slack digest. Adjust the schedule.
For targets with multiple clusters (prod, stage, dev), create separate CronJob files and Slack secrets for each.
kubectl apply -f <name>.cronjob.yaml
kubectl create job --from=cronjob/<name>-cluster-scan test-scan -n trivy-scanner
kubectl logs -f -n trivy-scanner job/test-scanCheck Slack. Clean up:
kubectl delete job test-scan -n trivy-scanner| EKS — Azure | EKS — GCP | AKS (own cluster) | |
|---|---|---|---|
| Deployed on | Central cluster | Central cluster | Target's cluster |
| Scan mode | --registries-only + --repos-only |
--gcr-only |
--cluster-only |
| What it scans | Everything in ACR + all ADO repos | Everything in GCR with latest tag |
Only images running on the cluster |
| Noise level | Higher (includes unused images) | Medium (filtered by tag + exclusions) | Low (only deployed images) |
| Secrets needed | 3 (azure, ado, slack) | 2 (gcp, slack) | 1 (slack only) |
| Init container | Yes (az login + acr token) | Yes (gcloud auth) | No |
If some targets don't need GCP scanning, you can maintain a smaller image without the gcloud SDK:
# tag current image before rebuilding with gcloud
docker pull --platform linux/amd64 <registry>.azurecr.io/vulnerability-scanner:latest
docker tag <registry>.azurecr.io/vulnerability-scanner:latest <registry>.azurecr.io/vulnerability-scanner:v1-no-gcloud
docker push <registry>.azurecr.io/vulnerability-scanner:v1-no-gcloudThen point cluster-only CronJobs at :v1-no-gcloud to avoid pulling the larger image.
Delete and recreate. Next run picks up new values automatically:
kubectl delete secret <name>-slack -n trivy-scanner
kubectl create secret generic <name>-slack \
--namespace trivy-scanner \
--from-literal=webhook-url="<new-webhook>" \
--from-literal=bot-token="<same-or-new-token>" \
--from-literal=channel-id="<new-channel-id>"To trigger a run after updating:
kubectl create job --from=cronjob/<cronjob-name> test-scan -n trivy-scanner
kubectl delete job test-scan -n trivy-scannerTo stop a CronJob from running without deleting it:
kubectl patch cronjob <cronjob-name> -n trivy-scanner -p '{"spec":{"suspend":true}}'To resume:
kubectl patch cronjob <cronjob-name> -n trivy-scanner -p '{"spec":{"suspend":false}}'kubectl delete cronjob <name>-acr-scan -n trivy-scanner
kubectl delete secret <name>-azure-creds -n trivy-scanner
kubectl delete secret <name>-ado-creds -n trivy-scanner
kubectl delete secret <name>-slack -n trivy-scannerDelete the app registration from the Azure tenant.
kubectl delete cronjob <name>-gcr-scan -n trivy-scanner
kubectl delete secret <name>-gcp-creds -n trivy-scanner
kubectl delete secret <name>-slack -n trivy-scannerRemove the service account's IAM bindings from each GCP project, or delete the service account.
kubectl delete cronjob <name>-cluster-scan -n trivy-scanner
kubectl delete secret <name>-slack -n trivy-scanner
kubectl delete clusterrolebinding trivy-reader-binding
kubectl delete serviceaccount trivy-reader -n trivy-scanner
kubectl delete secret acr-pull -n trivy-scanner
kubectl delete namespace trivy-scannerMinimal. The pod runs for 5–10 minutes then terminates. Requests 256Mi memory and 250m CPU, limits of 1Gi and 1 CPU. Nothing persists between scans — temp directories, cloned repos, and reports all live inside the container and get deleted when the pod finishes. Old completed pods are just metadata (controlled by successfulJobsHistoryLimit).
The Docker image is cached on the node by the container runtime. Only the first pull after a rebuild downloads the image. Kubernetes garbage-collects unused images when disk gets low.
After any code changes, rebuild and push:
az login
az acr login --name <registry-name>
docker build --platform linux/amd64 -t <registry-name>.azurecr.io/vulnerability-scanner:latest .
docker push <registry-name>.azurecr.io/vulnerability-scanner:latestAlways build with --platform linux/amd64 if you're on Apple Silicon — without it you'll get an ARM image that won't run on amd64 nodes.
Existing CronJobs pick up the new image on their next run (since they pull :latest).
Init:Error — init container failed. Check logs: kubectl logs <pod> -c azure-login -n trivy-scanner (Azure) or kubectl logs <pod> -c gcp-login -n trivy-scanner (GCP). Usually bad credentials or wrong subscription/project permissions.
ErrImagePull / ImagePullBackOff — scanner image can't be pulled. Either the image wasn't pushed, the pull secret is wrong, or you built on ARM (Apple Silicon) without --platform linux/amd64.
Error (but Slack worked) — the script exits 1 when it finds critical/high CVEs. Add || true to the args in the CronJob to get Completed status instead.
FileNotFoundError: 'git' — git isn't installed in the Docker image. Add git to the Dockerfile's apt-get install line, rebuild and push.
Clone failed — usually wrong PAT, expired token, or the repo's default branch name doesn't match. Check ADO PAT permissions.
Forbidden on kubectl — you have Azure Owner but not AKS RBAC. Need Azure Kubernetes Service RBAC Reader (or higher) assigned on the AKS resource.
0 unique images found — kubectl isn't in the Docker image. Check the Dockerfile includes the kubectl install step, rebuild and push.
Unauthorized when pushing image — run az login then az acr login --name <registry-name> before pushing.
Failed to list GCP projects — the service account doesn't have Viewer role on all projects. Check with: gcloud projects get-iam-policy <project> --flatten="bindings[].members" --filter="bindings.members:<sa-email>" --format="table(bindings.role)". It needs both roles/viewer and roles/artifactregistry.reader.
GCR scan finds fewer images than local run — the service account has access to fewer projects than your personal account. Grant Viewer + Artifact Registry Reader on the missing projects.
Docker no matching manifest for linux/arm64 — you're on Apple Silicon trying to pull an amd64 image. Use docker pull --platform linux/amd64 instead.
docker not in system PATH (GCP init container warning) — this is normal and can be ignored. The init container uses gcloud auth configure-docker which warns about Docker not being in PATH, but the credential helper file is still created correctly.
GCR report showing too many images — tune the exclude_repos list in config.yaml to filter out App Engine (appengine), Cloud Functions (gcf), and other auto-generated images. Only images with a latest tag are scanned.