Secure CI/CD Pipeline with Vulnerability Gating

Build a GCP pipeline that blocks vulnerable containers from reaching production.

Introduction

30 Second Summary

Every time you deploy a container to the cloud, you're trusting that its underlying software is free of known security holes. Without an automated check, a single outdated dependency can expose your entire application to attackers.

In this project, you will build a CI/CD pipeline on Google Cloud that automatically scans container images for vulnerabilities and blocks deployment when critical issues are found. You'll watch the pipeline reject a vulnerable image, fix the problem, and then deploy a clean container to a live service.

What You'll Build

A security-gated Cloud Build pipeline that builds a container image, scans it for known vulnerabilities using On-Demand Scanning, and only deploys clean images to Cloud Run.

By the end of this project, you'll have:

  • A Cloud Build pipeline that automates container building, vulnerability scanning, and deployment in a single workflow.
  • A security gate that blocks images with HIGH or CRITICAL vulnerabilities from ever reaching production.
  • A live Cloud Run service deployed only after passing your pipeline's vulnerability checks.
  • Secret Mission: Lock down your supply chain with Binary Authorization so only images built by Cloud Build can ever be deployed.

Are there any prerequisites?

You'll need a GCP account with billing enabled and free credits available. You should also have the Google Cloud CLI (gcloud) installed on your machine.

Familiarity with Docker and Artifact Registry is helpful but not required.

Before We Start

Before We Start...

Enable APIs and Configure IAM

Before you can build a pipeline that scans containers for vulnerabilities, Google Cloud needs to know which services your project will use.

In this step, you will enable the four APIs that power your pipeline and grant the Cloud Build service account the permissions it needs to scan images, deploy to Cloud Run, and manage service identities.

In this step, get ready to:
  • Enable the Cloud Build, Artifact Registry, On-Demand Scanning, and Cloud Run APIs.
  • Grant IAM roles to the Cloud Build service account for scanning, deploying, and impersonating service accounts.
  • Verify that the IAM bindings are correctly applied.
Set your project and enable the required APIs

Every GCP service sits behind an API that must be explicitly enabled before you can use it.

  • Open your terminal by pressing the Windows key, typing PowerShell, and pressing Enter.
  • Sign in to the Google Cloud CLI by running this command:
gcloud auth login

What does this command do?

This opens your web browser so you can sign in with your Google account. After you grant access, the CLI stores your credentials locally for all future commands.

  • A browser window opens. Sign in with the Google account that has your GCP project, then click Allow to grant access.
  • Back in your terminal, you should see a confirmation message containing your account email.
  • Set your active project by running this command:
gcloud config set project [[PROJECT_ID="your-gcp-project-id"]]
  • You should see the output: Updated property [core/project].
  • Enable all four APIs your pipeline needs by running this command:
gcloud services enable cloudbuild.googleapis.com artifactregistry.googleapis.com ondemandscanning.googleapis.com run.googleapis.com

What does this command do?

This activates four services on your project in one command:

  • cloudbuild.googleapis.com runs your CI/CD pipeline steps.
  • artifactregistry.googleapis.com stores your container images.
  • ondemandscanning.googleapis.com scans images for known vulnerabilities.
  • run.googleapis.com hosts your deployed container as a serverless service.
  • Confirm the APIs are enabled by running:
gcloud services list --enabled --filter="name:(cloudbuild OR artifactregistry OR ondemandscanning OR run)"
  • You should see all four API names listed in the output.

Not seeing all four APIs?

Make sure billing is enabled on your project. APIs cannot be activated without a billing account attached.

If you see a permission error, confirm you are signed in as the project Owner by running gcloud config list account.

help me troubleshoot why my GCP APIs are not enabling

Identify the Cloud Build service account and grant IAM roles

Cloud Build uses a dedicated service account to run your pipeline steps. This account needs explicit permission to scan images, deploy to Cloud Run, and act as other service accounts.

  • Retrieve your project number by running this command:
gcloud projects describe [[PROJECT_ID="your-gcp-project-id"]] --format="value(projectNumber)"

What is the project number?

Every GCP project has both a human-readable ID (like my-cool-project) and a numeric project number (like 123456789012). The Cloud Build service account email uses the project number, not the ID.

  • Copy the number from the output. Your Cloud Build service account email is PROJECT_NUMBER@cloudbuild.gserviceaccount.com where you replace PROJECT_NUMBER with the number you just copied.
  • Grant the On-Demand Scanning Admin role by running this command:
gcloud projects add-iam-policy-binding [[PROJECT_ID="your-gcp-project-id"]] --member="serviceAccount:[[PROJECT_NUMBER="your-project-number"]]@cloudbuild.gserviceaccount.com" --role="roles/ondemandscanning.admin"

What does this role do?

The roles/ondemandscanning.admin role lets Cloud Build trigger vulnerability scans and read the results. Without it, the scan step in your pipeline would fail with a permission denied error.

  • Grant the Cloud Run Admin role by running this command:
gcloud projects add-iam-policy-binding [[PROJECT_ID="your-gcp-project-id"]] --member="serviceAccount:[[PROJECT_NUMBER="your-project-number"]]@cloudbuild.gserviceaccount.com" --role="roles/run.admin"
  • Grant the Service Account User role by running this command:
gcloud projects add-iam-policy-binding [[PROJECT_ID="your-gcp-project-id"]] --member="serviceAccount:[[PROJECT_NUMBER="your-project-number"]]@cloudbuild.gserviceaccount.com" --role="roles/iam.serviceAccountUser"

Why does Cloud Build need three roles?

Each role follows the principle of least privilege:

  • roles/ondemandscanning.admin allows scanning container images.
  • roles/run.admin allows creating and updating Cloud Run services.
  • roles/iam.serviceAccountUser allows Cloud Build to act as the Cloud Run runtime service account during deployment.

Granting only the permissions needed keeps your project secure. If the service account were compromised, the blast radius is limited to these three capabilities.

  • After each command, you should see the updated IAM policy printed to the terminal. This confirms the binding was added.

Seeing a permission denied error?

You need the Owner or IAM Admin role on the project to grant IAM bindings. Confirm your account has the right access.

If you see an error about the service account not existing, double-check that you replaced the project number correctly. Run the gcloud projects describe command again to confirm.

help me fix a permission denied error when granting IAM roles in GCP

Verify the roles are correctly bound

Before building your pipeline, confirm that all three roles are attached to the correct service account.

  • List the IAM bindings for your Cloud Build service account by running this command:
gcloud projects get-iam-policy [[PROJECT_ID="your-gcp-project-id"]] --flatten="bindings[].members" --filter="bindings.members:[[PROJECT_NUMBER="your-project-number"]]@cloudbuild.gserviceaccount.com" --format="table(bindings.role)"

What does this command do?

The --flatten flag expands the nested bindings list into individual rows. The --filter flag narrows results to only bindings that include your Cloud Build service account. The --format flag displays just the role names in a clean table.

  • You should see at least these three roles in the output:
  • roles/ondemandscanning.admin
  • roles/run.admin
  • roles/iam.serviceAccountUser

Missing a role in the output?

Re-run the specific gcloud projects add-iam-policy-binding command for the missing role. Make sure the service account email matches exactly, including the project number.

help me figure out why my IAM role binding is not showing up

Your project is locked and loaded. Next up, you will create the Artifact Registry repository and write the application code that your pipeline will build and scan.

Create the Artifact Registry Repository and Application

Your pipeline needs a container registry to store the images it builds. Artifact Registry is Google Cloud's managed service for storing and managing Docker images.

In this step, you'll create a Docker repository in Artifact Registry, then write the Python application and Dockerfiles that your pipeline will use to build container images.

In this step, get ready to:
  • Create an Artifact Registry Docker repository.
  • Write a Python HTTP server for Cloud Run to serve.
  • Create two Dockerfiles to test vulnerable and clean builds.
Create a Docker repository in Artifact Registry

Artifact Registry organizes container images into repositories. You need a Docker-format repository before your pipeline can push images to it.

  • Create the repository by running this command:
gcloud artifacts repositories create secure-pipeline-repo --repository-format=docker --location=us-central1 --description="Docker repository for secure pipeline images"

What does this command do?

  • secure-pipeline-repo is the name of your new repository.
  • --repository-format=docker tells Artifact Registry this repository stores Docker container images.
  • --location=us-central1 sets the region where your images will be stored.
  • Verify the repository was created by running:
gcloud artifacts repositories list

You should see secure-pipeline-repo listed with format DOCKER and location us-central1.

Not seeing the repository?

  • Make sure the Artifact Registry API is enabled. Run gcloud services list --enabled and check for artifactregistry.googleapis.com in the output.
  • Confirm your active project is set correctly with gcloud config get-value project.
  • Help me troubleshoot why my Artifact Registry repository was not created.
Write the Python HTTP server application

Your pipeline needs an application to containerize. You'll write a minimal Python HTTP server that Cloud Run will serve once the image passes vulnerability scanning.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir secure-pipeline
cd secure-pipeline
  • Confirm the folder exists by running:
ls

You should see the secure-pipeline folder listed in the output.

  • Open the secure-pipeline folder in your code editor.
  • Create a new file called app.py and paste the following code into it:
import os
from http.server import HTTPServer, BaseHTTPRequestHandler


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.end_headers()
        self.wfile.write(b"<h1>Hello from Secure Pipeline!</h1>\n")
        self.wfile.write(b"<p>This container passed vulnerability scanning.</p>\n")


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8080))
    server = HTTPServer(("0.0.0.0", port), Handler)
    print(f"Server running on port {port}")
    server.serve_forever()

What does this code do?

  • Handler defines what happens when someone visits your app. It responds with a simple HTML page confirming the container passed scanning.
  • The PORT environment variable lets Cloud Run tell your app which port to listen on. It defaults to 8080 if not set.
  • HTTPServer starts a simple web server that listens for incoming HTTP requests on all network interfaces.
  • Save app.py.

File not saving?

  • Make sure you created the file inside the secure-pipeline folder on your Desktop, not in a different directory.
  • Check that the filename is exactly app.py with no extra spaces or extensions.
Write the Dockerfiles for vulnerable and clean builds

To see your security gate in action, you need two Dockerfiles. One uses a deliberately older Python base image that has known vulnerabilities. The other uses a current, patched image that should pass the scan.

  • In your secure-pipeline folder, create a new file called Dockerfile.vulnerable and paste the following code:
FROM python:3.9-slim

WORKDIR /app

COPY app.py .

ENV PORT=8080
EXPOSE 8080

CMD ["python", "app.py"]

Why use an older base image?

The python:3.9-slim image contains known vulnerabilities in its underlying OS packages. When your pipeline scans this image, it will detect HIGH or CRITICAL severity issues and block the deployment.

This is intentional. You want to see the security gate reject a bad image before you deploy a clean one.

  • Save Dockerfile.vulnerable.
  • Create another new file called Dockerfile (no extension) and paste the following code:
FROM python:3.12-slim

WORKDIR /app

COPY app.py .

ENV PORT=8080
EXPOSE 8080

CMD ["python", "app.py"]

What is different about this Dockerfile?

The only difference is the base image. python:3.12-slim is a current, patched image with far fewer known vulnerabilities.

After you see the pipeline fail with the vulnerable image, you'll switch to this Dockerfile and watch the build pass through the security gate cleanly.

  • Save Dockerfile.
  • Confirm all three files exist in your secure-pipeline folder by running:
ls

You should see app.py, Dockerfile, and Dockerfile.vulnerable in the output.

Missing a file?

  • Check that Dockerfile has no file extension. Some editors add .txt automatically. Rename it if needed.
  • Make sure all files are saved inside secure-pipeline on your Desktop, not in a subfolder or the home directory.
  • Help me figure out why my project files are not showing up in my secure-pipeline folder.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files.

ⓧ I'd like to double check the full code

Here is the complete code for each file in your secure-pipeline folder.

app.py:

import os
from http.server import HTTPServer, BaseHTTPRequestHandler


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.end_headers()
        self.wfile.write(b"<h1>Hello from Secure Pipeline!</h1>\n")
        self.wfile.write(b"<p>This container passed vulnerability scanning.</p>\n")


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8080))
    server = HTTPServer(("0.0.0.0", port), Handler)
    print(f"Server running on port {port}")
    server.serve_forever()

Dockerfile:

FROM python:3.12-slim

WORKDIR /app

COPY app.py .

ENV PORT=8080
EXPOSE 8080

CMD ["python", "app.py"]

Dockerfile.vulnerable:

FROM python:3.9-slim

WORKDIR /app

COPY app.py .

ENV PORT=8080
EXPOSE 8080

CMD ["python", "app.py"]

Your Artifact Registry repository is ready and your application files are in place. Next up, you'll write the Cloud Build pipeline that ties everything together with automated vulnerability scanning.

Write the Cloud Build Pipeline with Vulnerability Scanning

Your application code and Dockerfiles are ready. Now you need the pipeline configuration that tells Cloud Build exactly what to do with your code at each stage.

Cloud Build reads a file called cloudbuild.yaml to define each step of the pipeline. You'll write a five-step pipeline that builds your container image, scans it for vulnerabilities, blocks the pipeline if anything dangerous is found, pushes clean images to Artifact Registry, and deploys to Cloud Run.

In this step, get ready to:
  • Create the cloudbuild.yaml pipeline configuration file.
  • Understand how pipeline steps share data using workspace files.
  • Review the security gate logic that blocks vulnerable images.
Create the cloudbuild.yaml pipeline configuration

The cloudbuild.yaml file lives in the same directory as your application code. Cloud Build processes each step sequentially, stopping the entire pipeline if any step exits with a non-zero status code.

  • Create a new file called cloudbuild.yaml in your project directory (the same folder that contains app.py and your Dockerfiles).
  • Paste the following pipeline configuration into the file:
steps:
  - id: build
    name: gcr.io/cloud-builders/docker
    entrypoint: /bin/bash
    args:
      - -c
      - |
        docker build -t us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest -f ./Dockerfile . &&
        docker image inspect us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest --format \
        '{{index .RepoTags 0}}@{{.Id}}' > /workspace/image-digest.txt &&
        cat /workspace/image-digest.txt
  - id: scan
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud artifacts docker images scan us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest \
        --format='value(response.scan)' > /workspace/scan_id.txt
  - id: severity-check
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud artifacts docker images list-vulnerabilities $(cat /workspace/scan_id.txt) \
        --format='value(vulnerability.effectiveSeverity)' | if grep -Exq 'CRITICAL|HIGH'; \
        then echo 'Failed vulnerability check' && exit 1; else exit 0; fi
  - id: push
    name: gcr.io/cloud-builders/docker
    entrypoint: /bin/bash
    args:
      - -c
      - |
        docker push us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest
  - id: deploy
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud run deploy secure-app \
        --image=us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest \
        --region=us-central1 \
        --platform=managed \
        --allow-unauthenticated
images:
  - 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest'

What does this pipeline do?

This pipeline has five sequential steps. If any step fails, the entire pipeline stops.

  • build builds the Docker image from your Dockerfile and writes the image digest to a workspace file for traceability.
  • scan runs On-Demand Scanning on the built image and saves the scan operation ID to /workspace/scan_id.txt.
  • severity-check reads the scan results and looks for CRITICAL or HIGH severity vulnerabilities. If any are found, it exits with code 1 to stop the pipeline.
  • push pushes the verified-clean image to your Artifact Registry repository.
  • deploy deploys the image to a Cloud Run service named secure-app in us-central1.

The images field at the bottom tells Cloud Build to record the image in build metadata. This is needed for Binary Authorization attestation in the Secret Mission.

  • Save cloudbuild.yaml.
  • Confirm the file exists by running:
ls

You should see cloudbuild.yaml listed alongside app.py, Dockerfile, and Dockerfile.vulnerable.

Don't see cloudbuild.yaml in the output?

  • Make sure you saved the file in the same directory where app.py lives. Run ls to confirm you're in the right folder.
  • Check that the filename is exactly cloudbuild.yaml with no extra extension (not cloudbuild.yaml.txt).
  • Help me find my cloudbuild.yaml file.
Understand how workspace file passing works

Each step in a Cloud Build pipeline runs inside its own isolated container. Once a step finishes, its container is discarded. So how does the severity-check step read the scan results produced by the scan step?

What is the /workspace directory?

Cloud Build automatically mounts a persistent /workspace volume to every step container. When your source code is loaded, it goes into /workspace. Any file a step writes there persists for all later steps.

Your pipeline uses this to bridge steps. The scan step writes the scan operation ID to /workspace/scan_id.txt. The severity-check step reads that same file with $(cat /workspace/scan_id.txt) to look up the vulnerability results.

Similarly, the build step writes the image digest to /workspace/image-digest.txt for traceability. Think of /workspace as a shared folder that every step can read from and write to.

Why does the pipeline use $PROJECT_ID?

Cloud Build automatically populates $PROJECT_ID with your active GCP project ID at build time. This means you never need to hardcode your project ID into the pipeline configuration. The image path us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest resolves correctly for any project that runs this pipeline.

Review the severity check logic

The severity-check step is the security gate. It determines whether the pipeline continues to push and deploy, or stops entirely.

How does the grep pattern block the build?

The command gcloud artifacts docker images list-vulnerabilities outputs one severity level per line (CRITICAL, HIGH, MEDIUM, or LOW). The output is piped into a grep -Exq 'CRITICAL|HIGH' command.

  • -E enables extended regex so the | acts as an OR operator.
  • -x matches the entire line (not partial matches).
  • -q runs in quiet mode (no output, just sets the exit code).

If grep finds a match (meaning CRITICAL or HIGH vulnerabilities exist), it returns exit code 0. The if statement then runs echo 'Failed vulnerability check' && exit 1. That exit 1 stops the entire pipeline. If no HIGH or CRITICAL vulnerabilities are found, the step exits cleanly and the pipeline continues to push and deploy.

✔️ Awesome, I've got everything!

Great. Double check you've saved cloudbuild.yaml and all four files are in your project directory.

ⓧ I'd like to double check the full code

Here is the complete cloudbuild.yaml file:

steps:
  - id: build
    name: gcr.io/cloud-builders/docker
    entrypoint: /bin/bash
    args:
      - -c
      - |
        docker build -t us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest -f ./Dockerfile . &&
        docker image inspect us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest --format \
        '{{index .RepoTags 0}}@{{.Id}}' > /workspace/image-digest.txt &&
        cat /workspace/image-digest.txt
  - id: scan
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud artifacts docker images scan us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest \
        --format='value(response.scan)' > /workspace/scan_id.txt
  - id: severity-check
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud artifacts docker images list-vulnerabilities $(cat /workspace/scan_id.txt) \
        --format='value(vulnerability.effectiveSeverity)' | if grep -Exq 'CRITICAL|HIGH'; \
        then echo 'Failed vulnerability check' && exit 1; else exit 0; fi
  - id: push
    name: gcr.io/cloud-builders/docker
    entrypoint: /bin/bash
    args:
      - -c
      - |
        docker push us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest
  - id: deploy
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud run deploy secure-app \
        --image=us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest \
        --region=us-central1 \
        --platform=managed \
        --allow-unauthenticated
images:
  - 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest'

Your pipeline configuration is complete. Next up, you'll submit this pipeline to Cloud Build and watch the security gate in action.

Run the Pipeline and Observe the Security Gate

Your pipeline is fully configured with build, scan, severity-check, push, and deploy steps. Now it is time to prove the security gate actually works.

You will submit two builds in this step. The first deliberately uses an outdated base image to trigger the vulnerability gate. The second uses a current, patched image to demonstrate that clean containers flow through to deployment on Cloud Run.

In this step, get ready to:
  • Submit a build with a vulnerable base image and watch the pipeline fail at the security gate.
  • Fix the pipeline to reference the patched base image.
  • Re-submit and watch the clean image deploy to Cloud Run.
Submit the build with the vulnerable base image

To see the security gate in action, you need the pipeline to build a container with known vulnerabilities. Your Dockerfile.vulnerable uses python:3.9-slim, an older base image with known security issues.

The pipeline's build step currently references -f ./Dockerfile. You will temporarily point it at Dockerfile.vulnerable so the scanner finds vulnerabilities to block.

  • In cloudbuild.yaml, find the build step's args section. Locate this line:
docker build -t us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest -f ./Dockerfile . &&
  • Replace -f ./Dockerfile with -f ./Dockerfile.vulnerable, so the line now looks like this:
docker build -t us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest -f ./Dockerfile.vulnerable . &&
  • Save cloudbuild.yaml.
  • Submit the build to Cloud Build by running this command:
gcloud builds submit --config cloudbuild.yaml

What happens when you submit?

Cloud Build uploads your local source files, then executes each pipeline step in sequence. Build logs stream directly to your terminal in real time.

This build will take 2-5 minutes. The build and scan steps will succeed, but the severity-check step will find HIGH or CRITICAL vulnerabilities in the python:3.9-slim base image and halt the pipeline.

  • Wait for the build to complete. In the log output, look for the message Failed vulnerability check from the severity-check step.
  • At the end of the output, you should see a summary line with STATUS: FAILURE. This confirms the security gate blocked the vulnerable image from being pushed or deployed.

Why did the pipeline stop here?

The severity-check step ran grep -Exq 'CRITICAL|HIGH' against the scan results. Because the older python:3.9-slim image contains packages with known HIGH or CRITICAL vulnerabilities, grep matched and the step exited with code 1.

When any step exits non-zero, Cloud Build halts the entire pipeline. The push and deploy steps never ran, so the vulnerable image never reached your registry or production.

Build failed for a different reason?

  • If you see a permissions error, double-check that the IAM roles from Step 1 are correctly bound to your Cloud Build service account.
  • If the scan step fails (not the severity-check step), confirm that the On-Demand Scanning API is enabled by running gcloud services list --enabled and looking for ondemandscanning.googleapis.com.
  • If the build step itself fails, make sure Dockerfile.vulnerable exists in the same directory where you ran the command.

Help me troubleshoot my Cloud Build pipeline failure

Fix the pipeline to use the patched base image

The security gate did its job. Now you need to point the pipeline back at the current, patched Dockerfile that uses python:3.12-slim.

  • In cloudbuild.yaml, find the line you changed in the build step:
docker build -t us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest -f ./Dockerfile.vulnerable . &&
  • Replace -f ./Dockerfile.vulnerable with -f ./Dockerfile, so the line now looks like this:
docker build -t us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest -f ./Dockerfile . &&
  • Save cloudbuild.yaml.

What is different about this Dockerfile?

Your main Dockerfile uses python:3.12-slim as its base image. This is a current release with up-to-date security patches, so the vulnerability scanner should not find HIGH or CRITICAL issues.

Re-submit the build and watch it deploy
  • Submit the build again by running this command:
gcloud builds submit --config cloudbuild.yaml

What should I expect this time?

This build will take 2-5 minutes again. This time, the severity-check step will find no HIGH or CRITICAL vulnerabilities. The pipeline will continue past the gate to push the image to Artifact Registry and deploy it to Cloud Run.

  • Wait for the build to finish. You should see the logs progress through all five steps: build, scan, severity-check, push, and deploy.
  • At the end of the output, look for STATUS: SUCCESS in the summary line.
  • In the deploy step's logs, look for the Cloud Run service URL. It will look something like https://secure-app-XXXXXXXX-uc.a.run.app.

Build still failing at severity-check?

  • Make sure you changed -f ./Dockerfile.vulnerable back to -f ./Dockerfile and saved the file before submitting.
  • Confirm your main Dockerfile starts with FROM python:3.12-slim. If it still says python:3.9-slim, you edited the wrong file.

Help me figure out why my second build is still failing

✔️ Awesome, I've got everything!

Great. Double check you have saved cloudbuild.yaml with the original -f ./Dockerfile reference restored.

ⓧ I'd like to double check the full code

Here is the complete cloudbuild.yaml as it should look now:

steps:
  - id: build
    name: gcr.io/cloud-builders/docker
    entrypoint: /bin/bash
    args:
      - -c
      - |
        docker build -t us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest -f ./Dockerfile . &&
        docker image inspect us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest --format \
        '{{index .RepoTags 0}}@{{.Id}}' > /workspace/image-digest.txt &&
        cat /workspace/image-digest.txt
  - id: scan
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud artifacts docker images scan us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest \
        --format='value(response.scan)' > /workspace/scan_id.txt
  - id: severity-check
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud artifacts docker images list-vulnerabilities $(cat /workspace/scan_id.txt) \
        --format='value(vulnerability.effectiveSeverity)' | if grep -Exq 'CRITICAL|HIGH'; \
        then echo 'Failed vulnerability check' && exit 1; else exit 0; fi
  - id: push
    name: gcr.io/cloud-builders/docker
    entrypoint: /bin/bash
    args:
      - -c
      - |
        docker push us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest
  - id: deploy
    name: gcr.io/google.com/cloudsdktool/cloud-sdk
    entrypoint: /bin/bash
    args:
      - -c
      - |
        gcloud run deploy secure-app \
        --image=us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest \
        --region=us-central1 \
        --platform=managed \
        --allow-unauthenticated
images:
  - 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-app:latest'

Your security gate just proved its worth. A vulnerable container was blocked, a clean container was deployed. In the next step, you will access your live service and review the security audit trail in the Google Cloud console.

Verify Your Deployed Service and Review Security Posture

Your pipeline successfully built, scanned, and deployed a clean container image to Cloud Run. The final piece is confirming the deployed service is live and reviewing the security evidence your pipeline generated.

In this step, you will hit the live service URL, inspect the vulnerability scan results stored in Artifact Registry, and review the Cloud Build audit trail showing your failed and successful builds.

In this step, get ready to:
  • Access your deployed Cloud Run service and confirm it returns the expected response.
  • View vulnerability scan results in the Google Cloud console.
  • Review Cloud Build history to see the audit trail of passed and failed builds.
Access the deployed Cloud Run service URL

Your pipeline deployed the application to Cloud Run with public access enabled. You can retrieve its URL and send a request to confirm it is serving traffic.

  • Retrieve your Cloud Run service URL by running this command:
gcloud run services describe secure-app --region=us-central1 --format='value(status.url)'

The command outputs a URL. Copy it for the next step.

  • Send a request to your service by running this command (replace the URL with the one from the previous output):
curl $(gcloud run services describe secure-app --region=us-central1 --format='value(status.url)')

What does this command do?

The inner gcloud run services describe command retrieves the service URL. The curl command sends an HTTP GET request to that URL, which triggers the do_GET method in your app.py server.

You should see the following HTML response in your terminal:

<h1>Hello from Secure Pipeline!</h1>
<p>This container passed vulnerability scanning.</p>

Why this response matters

This is not just a hello world message. It confirms that your container image survived the entire pipeline: it was built, scanned for vulnerabilities, passed the severity check, and was deployed to production. Only images that clear the security gate reach this point.

Not seeing the expected response?

If you get a permission denied error, check that the deploy step in your cloudbuild.yaml includes --allow-unauthenticated. If you see a 404 or connection refused error, confirm the service is running by checking the Cloud Run section of the Google Cloud console.

ask me for help debugging my Cloud Run service response

View vulnerability scan results in the Google Cloud console

The On-Demand Scanning results for your pushed image are stored in Artifact Registry. You can inspect them visually to understand what the scanner checks.

  • Open the Google Cloud console in your browser.
  • In the navigation menu, click Artifact Registry.
  • Click the secure-pipeline-repo repository.
  • Click the secure-app image.
  • Click the image digest (the latest tag entry).
  • Select the Vulnerabilities tab.

What should I see?

The Vulnerabilities tab displays the scan results for your pushed image. Since you deployed the python:3.12-slim image that passed the severity check, you should see either no vulnerabilities or only LOW/MEDIUM severity findings. No HIGH or CRITICAL entries should appear.

Each vulnerability entry shows its severity level, CVE identifier, affected package, and whether a fix is available. This is the same data your pipeline checked programmatically in the severity-check step.

Compare with the vulnerable image

If you want to see what HIGH/CRITICAL vulnerabilities look like, you can manually scan the vulnerable image. The contrast between python:3.9-slim and python:3.12-slim results shows exactly why your security gate blocked the first build.

Review Cloud Build history for the audit trail

Every build submission creates an audit record. Your project now has two builds: one that failed the security gate and one that passed. This history is crucial for compliance and incident response.

  • View your build history by running this command:
gcloud builds list

What should I see?

The output displays a table of builds with columns for ID, create time, duration, source, and status. You should see two entries:

  • One build with status FAILURE (the first build with the vulnerable python:3.9-slim base image that was blocked at the severity-check step).
  • One build with status SUCCESS (the second build with the clean python:3.12-slim base image that passed all steps).
  • You can also view this in the console. In the navigation menu, click Cloud Build, then select History.

The Build History page shows both builds with visual status indicators. Click on the failed build to see which step caused the failure.

Why the audit trail matters

In production, this build history serves as an audit log proving that every deployed image passed security checks. If a security incident occurs, you can trace exactly which images were scanned, what vulnerabilities existed at scan time, and whether the gate correctly blocked or allowed deployment.

This is a key requirement for compliance frameworks like SOC 2 and ISO 27001 that demand evidence of security controls in the deployment pipeline.

Only seeing one build?

If gcloud builds list shows only one build, make sure you submitted both builds in the previous step. The first build used Dockerfile.vulnerable (renamed to Dockerfile temporarily) and the second used the original Dockerfile with python:3.12-slim.

help me find my Cloud Build history

Secret mission

Enforce Binary Authorization

Your pipeline blocks vulnerable images, but what stops someone from bypassing the pipeline entirely and deploying a manually-pushed image? In this secret mission, you will lock down your supply chain with Binary Authorization so only images built by Cloud Build can ever be deployed to your Cloud Run service.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project's ongoing costs are minimal. Cloud Run scales to zero when idle and Artifact Registry storage is free up to 0.5 GB per month.

Resources you used:

  • Cloud Run service secure-app in us-central1.
  • Artifact Registry Docker repository secure-pipeline-repo in us-central1 (includes all container images).
  • IAM role bindings on your project (On-Demand Scanning Admin, Cloud Run Admin, IAM Service Account User).
  • Local project files (app.py, Dockerfile, Dockerfile.vulnerable, cloudbuild.yaml, policy.yaml).

Keep everything running

No action needed. Choose this if you are still actively building or plan to trigger more pipeline runs soon.

  • Cloud Run scales to zero when no requests come in. You are not charged for idle time.
  • Artifact Registry charges for storage only. A single container image stays well within the 0.5 GB free tier.
  • On-Demand Scanning charges $0.26 per scan. No new scans run unless you submit another build.

Pause - I'll come back to this later

Shut down the running service to free up any potential cost, but keep your repository and pipeline configuration for future use.

  • Delete the Cloud Run service by running:
gcloud run services delete secure-app --region=us-central1

Type Y when prompted to confirm.

  • Your Artifact Registry repository and pipeline YAML remain intact. Re-deploy anytime by submitting another build.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete the Cloud Run service by running:
gcloud run services delete secure-app --region=us-central1
  • Delete the Artifact Registry repository (this also removes all stored container images) by running:
gcloud artifacts repositories delete secure-pipeline-repo --location=us-central1

Type Y when prompted to confirm each deletion.

  • Remove the IAM role bindings you added to the Cloud Build service account. Replace the project number placeholder with your own:
gcloud projects remove-iam-policy-binding $(gcloud config get-value project) \
  --member='serviceAccount:'$(gcloud projects describe $(gcloud config get-value project) --format='value(projectNumber)')'-compute@developer.gserviceaccount.com' \
  --role='roles/ondemandscanning.admin'

gcloud projects remove-iam-policy-binding $(gcloud config get-value project) \
  --member='serviceAccount:'$(gcloud projects describe $(gcloud config get-value project) --format='value(projectNumber)')'-compute@developer.gserviceaccount.com' \
  --role='roles/run.admin'

gcloud projects remove-iam-policy-binding $(gcloud config get-value project) \
  --member='serviceAccount:'$(gcloud projects describe $(gcloud config get-value project) --format='value(projectNumber)')'-compute@developer.gserviceaccount.com' \
  --role='roles/iam.serviceAccountUser'
  • If you completed the Secret Mission, reset the Binary Authorization policy to its default (allow all images) by running:
gcloud container binauthz policy export > /tmp/default-policy.yaml
  • Open /tmp/default-policy.yaml in a text editor and replace the defaultAdmissionRule section with:
defaultAdmissionRule:
  evaluationMode: ALWAYS_ALLOW
  enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG
  • Import the updated policy by running:
gcloud container binauthz policy import /tmp/default-policy.yaml
  • Delete your local project files by running:
rm app.py Dockerfile Dockerfile.vulnerable cloudbuild.yaml policy.yaml

Nice Work!

Nice Work!

Nice work! You just built a security-gated CI/CD pipeline on Google Cloud that prevents vulnerable containers from ever reaching production.

You've learned how to:

  • Build a multi-step Cloud Build pipeline that automates container building, vulnerability scanning, and deployment to Cloud Run.
  • Implement a security gate using On-Demand Scanning severity thresholds that blocks images with HIGH or CRITICAL vulnerabilities from reaching production.
  • Configure least-privilege IAM roles for a CI/CD service account and deploy a verified-clean container to Cloud Run.
  • Secret Mission: Enforce supply chain security with Binary Authorization, ensuring only Cloud Build-produced images can be deployed.

Ready to quiz yourself?