Skip to content

Repository files navigation

Vulnerability-Scanner (Trivy Stack)

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.

Contents


What it scans

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.


How it fits into the wider picture

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

Running locally (macOS)

Prerequisites

brew install trivy

For GCR scanning, also install the Google Cloud SDK:

brew install --cask google-cloud-sdk

Setup

cd vulnerability-scanner

pip install -r requirements.txt

cp config.yaml.example config.yaml

Auth and run (Azure)

Docker 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 tsv

Auth and run (GCP)

gcloud auth login
gcloud auth configure-docker eu.gcr.io

python scan.py --gcr-only

Other run modes

# 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-only

View reports

open ./reports/<registry-name>/vuln_report_<timestamp>.html
open ./reports/repos/repo_scan_<timestamp>.html
open ./reports/cluster/cluster_scan_<timestamp>.html

Config

acr:
  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-tmp

The 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.


Slack setup

  1. Create a Slack app at https://api.slack.com/apps
  2. OAuth & Permissions → add scopes: files:write, chat:write
  3. Install to workspace
  4. Copy the Bot User OAuth Token (xoxb-...)
  5. Create an Incoming Webhook for the target channel
  6. Invite the bot to the channel: /invite @YourBotName
  7. Get the channel ID: right-click channel → View channel details → scroll to bottom

Deployment

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.


One-Time Setup

These steps only need to be done once, regardless of how many targets you add.

1. Create an ACR (or any container registry) to host the scanner image

az group create --name <resource-group> --location <region>

az acr create --resource-group <resource-group> --name <registry-name> --sku Basic

2. Build and push the Docker image

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:latest

Always build with --platform linux/amd64 if you're on Apple Silicon.

3. Create a service principal for image pulls

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.

4. Create the namespace on your cluster

kubectl create namespace trivy-scanner

5. Create the image pull secret

kubectl 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.


EKS Deployment — Azure subscriptions (no AKS)

Scans ACR registries and ADO repos in an Azure subscription remotely from a central cluster.

1. Create an app registration in the target Azure tenant

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>

2. Create an ADO PAT (if scanning repos)

Go to https://dev.azure.com/<org>/_usersSettings/tokens and create a PAT with:

  • Code (Read)
  • Build (Read)

3. Create the secrets

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>"

4. Create the CronJob

cp cronjob-azure-eks.yaml.example <name>.cronjob.yaml

Replace all <client> placeholders with <name>. Adjust the schedule.

5. Apply and test

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-scan

Check Slack. Clean up:

kubectl delete job test-scan -n trivy-scanner

EKS Deployment — GCP projects (no GKE)

Scans GCR images across GCP projects remotely from a central cluster.

1. Get a GCP service account key

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"
done

To 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.

2. Create the secrets

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>"

3. Create the CronJob

cp cronjob-gcr-eks.yaml.example <name>.cronjob.yaml

Replace 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).

4. Apply and test

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 scanner

To check the GCP auth init container:

kubectl logs -n trivy-scanner job/test-scan -c gcp-login

Clean up:

kubectl delete job test-scan -n trivy-scanner

AKS Deployment (target has its own cluster)

Scans 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.

1. Create the namespace

kubectl create namespace trivy-scanner

2. Create the image pull secret

kubectl create secret docker-registry acr-pull \
  --namespace trivy-scanner \
  --docker-server=<registry-name>.azurecr.io \
  --docker-username="<appId>" \
  --docker-password="<password>"

3. Create the Slack secret

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>"

4. Create a read-only service account

kubectl create serviceaccount trivy-reader -n trivy-scanner

kubectl create clusterrolebinding trivy-reader-binding \
  --clusterrole=view \
  --serviceaccount=trivy-scanner:trivy-reader

5. Create the CronJob

cp cronjob-aks.yaml.example <name>.cronjob.yaml

Replace 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.

6. Apply and test

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-scan

Check Slack. Clean up:

kubectl delete job test-scan -n trivy-scanner

Deployment comparison

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

Docker image tags

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-gcloud

Then point cluster-only CronJobs at :v1-no-gcloud to avoid pulling the larger image.


Updating secrets

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-scanner

Suspending and resuming a CronJob

To 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}}'

Removing a target

From central cluster (Azure)

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-scanner

Delete the app registration from the Azure tenant.

From central cluster (GCP)

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-scanner

Remove the service account's IAM bindings from each GCP project, or delete the service account.

From target's AKS

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-scanner

Resources used on the cluster

Minimal. 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.


Rebuilding the Docker image

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:latest

Always 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).


Troubleshooting

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.

About

DevSecOps-Vulnerability-Scanner

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages