Internal Customer Engineer Enablement

Build a CodeMender remediation pipeline on Cloud Build

You'll package CodeMender into a container, wire up GCS-backed state, and hand-write a Cloud Build pipeline that scans OWASP Juice Shop, verifies a real vulnerability, fixes it, and opens a Pull Request on your own fork — which you review, approve, and merge.

~60 min Intermediate GCP · Cloud Build · Artifact Registry · GCS GitHub

The big pictureWhat you are building

CodeMender (cm) is an AI security agent: a thin client that streams a codebase to a hardened backend which reasons about vulnerabilities and generates validated patches. You'll drive it from CI as four discrete Cloud Build steps:

  • findcm find scans a file and records vulnerabilities (severity, CWE, analysis).
  • verifycm find verify reproduces the top finding (it may build & run the app) to confirm it's real. This is why the image ships with node/npm.
  • fixcm fix generates the patch; your script applies it deterministically.
  • open-pr — commit to a branch, push to your fork, open a PR whose body is CodeMender's own analysis.
🗄️ Why a GCS bucket for state?

Each Cloud Build step is a fresh container. CodeMender keeps its config, identity key, and findings database (SQLite) under ~/.codemender — which would vanish between steps. You'll mount a GCS bucket with gcsfuse and point $HOME there, so cm init in the find step and the findings it records are still available to verify and fix.

🔑 No CodeMender secret to manage

The CodeMender server endpoint and credential are baked into the cm binary. The only secret you create is a GitHub token so the pipeline can push and open a PR.

Why it's built this wayDesign decisions

The architecture diagram above shows the flow: four Cloud Build steps that share CodeMender state through a gcsfuse-mounted GCS bucket, ending in a Pull Request you approve. The choices behind it:

DecisionWhy
Separate steps for find / verify / fix / open-prEach phase is visible and independently retryable in the build log; mirrors how you'd stage a real security pipeline.
GCS + gcsfuse for CodeMender stateEvery Cloud Build step is a fresh container. Mounting a bucket at $HOME persists cm init's config, identity, and the findings state.db across steps.
Full verification, not skippedCodeMender reproduces the exploit (it builds & runs the app), so a fix targets a confirmed bug. This is why the image ships node/npm and why verify is the slow step.
Deterministic patch applyThe fix step applies CodeMender's stored patch rather than trusting the working tree, so the result is identical every run.
Human-in-the-loop PRThe pipeline never writes to master. CodeMender proposes; you review, approve, and merge — the approval gate stays with a person.
Minimal toolbox imageThe image carries only cm + build tools; you author the orchestration. Nothing is hidden, so you learn the CLI and can adapt it.
Single-file scan targetBecause the checkout lives in /workspace (not /tmp), a one-file target keeps CodeMender's sandbox scoped to that file — fast and deterministic for the lab.

Before you startPrerequisites

You'll drive the whole lab from your laptop — the CLIs submit builds to Cloud Build in your GCP project. (Prefer zero install? Cloud Shell has gcloud, git, and gh preinstalled.)

You needNotes
A GitHub accountYou'll fork Juice Shop into it.
A GCP project with billingCloud Build, Artifact Registry, GCS, and Secret Manager run here. Owner or Editor on the project is simplest.
gcloud, git, gh on your laptopInstall & authenticate below.
The CodeMender image URLProvided by your facilitator (see the next section). Access is granted org-wide, so there's nothing for you to request.
bash · on your laptop (once)
# Install: gcloud  https://cloud.google.com/sdk/docs/install
#          gh      https://cli.github.com     git  https://git-scm.com
gcloud auth login
gh auth login              # GitHub.com -> HTTPS -> grant the 'repo' scope
git --version && gh --version && gcloud --version | head -1
⚠ Use a throwaway lab project

Juice Shop is deliberately vulnerable. Keep it in an isolated fork and a lab GCP project; never expose it publicly.

Provided for youThe CodeMender image

📦 Your facilitator provides the image — you don't build it

The facilitator publishes the container to Artifact Registry and grants your organization read access. You'll get a URL like REGION-docker.pkg.dev/CENTRAL_PROJECT/codemender/codemender-ci:v0.2.0 to paste into _CM_IMAGE in Step 9. Your Cloud Build pulls it automatically.

For reference, this is exactly how that image is built — a deliberately minimal toolbox: the cm client plus the build/runtime tools the pipeline needs (node/npm so cm find verify can build & run Juice Shop, git, jq, and gcsfuse for state).

Dockerfile — reference only
container/Dockerfile
# CodeMender CI toolbox image.
#
# A deliberately MINIMAL image: the CodeMender client plus the build/runtime
# tools needed to (a) scan and remediate a Node.js project and (b) let
# `cm find verify` actually build & run OWASP Juice Shop to reproduce an
# exploit. It carries NO orchestration logic — participants author the find /
# verify / fix scripts and the Cloud Build pipeline themselves during the lab.
#
# Base: Debian-based Node.js (glibc). Required because:
#   • `cm` is dynamically linked against glibc, and
#   • Juice Shop builds and runs on Node 22 (npm, node-gyp, etc.).
FROM node:22-bookworm-slim

# Build & runtime dependencies:
#   git, ca-certificates      clone/commit/push; TLS to the CM server & GitHub
#   curl, jq                  GitHub REST API; parse `cm report` JSON output
#   build-essential, python3  node-gyp / native npm modules used by Juice Shop
#   fuse3, gcsfuse            mount a GCS bucket for CodeMender state (see lab)
RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      git ca-certificates curl jq gnupg build-essential python3 fuse3 \
 && curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \
      | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg \
 && echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt gcsfuse-bookworm main" \
      > /etc/apt/sources.list.d/gcsfuse.list \
 && apt-get update \
 && apt-get install -y --no-install-recommends gcsfuse \
 && rm -rf /var/lib/apt/lists/*

# The CodeMender client. Build context must contain the `cm-linux` binary.
COPY cm-linux /usr/local/bin/cm
RUN chmod 0755 /usr/local/bin/cm

# Sanity checks: the toolbox has everything the lab relies on.
RUN cm --version && node --version && npm --version \
 && git --version && jq --version && gcsfuse --version

WORKDIR /workspace
1

Fork & clone Juice Shop

Fork into your account so all branches, commits, and PRs stay yours.

bash
gh repo fork juice-shop/juice-shop --clone --fork-name juice-shop
cd juice-shop
git remote -v      # origin -> your fork; upstream -> juice-shop
ℹ Default branch is master

Juice Shop's default branch is master, not main — remember that for _BASE_BRANCH.

2

Configure git & GitHub auth

bash
gh auth status
git config --global user.name  "Your Name"
git config --global user.email "you@example.com"
3

Prepare your GCP project

bash
export PROJECT_ID=your-gcp-project
export REGION=us-central1
gcloud config set project "$PROJECT_ID"

# Cloud Build runs the pipeline; Secret Manager holds the token; GCS holds CM state
gcloud services enable \
  cloudbuild.googleapis.com secretmanager.googleapis.com storage.googleapis.com
4

Create the CodeMender state bucket

This GCS bucket holds CodeMender's config, identity key, and findings database so they survive between the separate pipeline steps. Bucket names are globally unique — prefix with your project id.

bash
export CM_STATE_BUCKET="${PROJECT_ID}-cm-state"
gcloud storage buckets create "gs://$CM_STATE_BUCKET" \
  --location="$REGION" --uniform-bucket-level-access
5

Store a GitHub token in Secret Manager

The pipeline pushes a branch and opens a PR, so it needs a GitHub token with write access. Pick one method; both end with the same gh-pat secret.

Option A — Fine-grained PAT recommended

  1. GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.
  2. Repository access → Only select repositories → your juice-shop fork.
  3. Repository permissions: Contents → Read and write, Pull requests → Read and write.
  4. Generate and copy the token (github_pat_…).
bash · input hidden, kept out of shell history
read -rs GH_PAT
printf '%s' "$GH_PAT" | gcloud secrets create gh-pat --data-file=-
unset GH_PAT

Option B — Reuse the gh CLI token

Quicker if you already use the GitHub CLI; the token is broader-scoped, so delete the secret when done.

bash
gh auth login            # GitHub.com -> HTTPS -> grant 'repo' scope
gh auth status           # confirm the account that owns your fork is active
gh auth token | gcloud secrets create gh-pat --data-file=-
ℹ Updating the secret later

If gh-pat exists, add a version instead:
gh auth token | gcloud secrets versions add gh-pat --data-file=-

🔒 The token lives only in Secret Manager

The pipeline reads it at run time via availableSecrets; the scripts strip any trailing newline and redact it from logs. Delete it in Cleanup.

6

Grant Cloud Build access

Cloud Build runs as a service account that needs to read your gh-pat secret and read/write the state bucket. (Image access is already granted org-wide by your facilitator — nothing to request.)

ℹ Two possible service accounts — grant both

Depending on how your project was created, Cloud Build uses the legacy PROJECT_NUMBER@cloudbuild.gserviceaccount.com or the Compute Engine PROJECT_NUMBER-compute@developer.gserviceaccount.com. The commands below grant both, so it works either way.

bash
export PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')
export CB_LEGACY="${PROJECT_NUMBER}@cloudbuild.gserviceaccount.com"
export CB_COMPUTE="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"

for SA in "$CB_LEGACY" "$CB_COMPUTE"; do
  # read the GitHub token
  gcloud secrets add-iam-policy-binding gh-pat \
    --member="serviceAccount:$SA" --role="roles/secretmanager.secretAccessor"
  # read/write CodeMender state in the bucket
  gcloud storage buckets add-iam-policy-binding "gs://$CM_STATE_BUCKET" \
    --member="serviceAccount:$SA" --role="roles/storage.objectAdmin"
done
# Image access is already granted org-wide by your facilitator - nothing to send.
7

Write the phase scripts

In an empty working directory, create a scripts/ folder with the five files below. These are the heart of the lab: they orchestrate CodeMender and glue the phases together by parsing (grepping) the findings.

scripts/cm-env.sh — state & the finding-selector

Sourced by the others. It mounts the state bucket with gcsfuse, points $HOME at a per-run folder, initialises CodeMender once, and defines top_finding_id — the parser that reads cm report and returns the highest-severity finding.

scripts/cm-env.sh
#!/usr/bin/env bash
#
# cm-env.sh — shared setup, sourced by find.sh / verify.sh / fix.sh.
#
# CodeMender keeps its state (config, Ed25519 identity, findings SQLite DB,
# session logs, generated patches) under $HOME/.codemender. In this pipeline
# each phase runs as a SEPARATE Cloud Build step — a fresh container — so that
# state would normally be lost between find, verify, and fix.
#
# To persist it we mount a GCS bucket with gcsfuse and point $HOME at a
# per-run folder inside it. `cm init` writes there once; every later step
# re-mounts the same bucket and finds the identity + findings DB already there.
set -euo pipefail

: "${CM_STATE_BUCKET:?set CM_STATE_BUCKET (GCS bucket for CodeMender state)}"
: "${STATE_ID:?set STATE_ID (per-run isolation, e.g. the Cloud Build \$BUILD_ID)}"

export WORKSPACE="${WORKSPACE:-/workspace}"
export REPO_DIR="${REPO_DIR:-$WORKSPACE/repo}"
export HOME="/mnt/cmstate/${STATE_ID}"          # cm writes ~/.codemender here

# --- Mount the state bucket (once per step/container) ------------------------
mkdir -p /mnt/cmstate
if ! mount | grep -q ' /mnt/cmstate '; then
  echo "🗄️  mounting gs://${CM_STATE_BUCKET} at /mnt/cmstate (gcsfuse)"
  gcsfuse --implicit-dirs "${CM_STATE_BUCKET}" /mnt/cmstate
fi
mkdir -p "$HOME"

# --- One-time CodeMender init for this run ----------------------------------
if [[ ! -f "$HOME/.codemender/config.yaml" ]]; then
  echo "⚙️  cm init (state → gs://${CM_STATE_BUCKET}/${STATE_ID})"
  cm init
  # CI-friendly config: git VCS, no interactive prompts, fresh scans.
  cat > "$HOME/.codemender/config.yaml" <<'YAML'
server: {}
scan:
  extensions:
    include: [".ts", ".js"]
    exclude: [".min.js", ".spec.ts", ".test.ts"]
  incremental: false
output:
  format: table
tools:
  confirm_commands: false
  confirm_writes: false
vcs:
  type: git
build:
  command: ""
YAML
fi

# --- Helper: highest-severity finding UUID from `cm report` (empty if none) --
# This is the "grep the results for the next command" glue: parse the findings
# CodeMender stored and hand the top one to verify / fix.
top_finding_id() {
  cm report -f json 2>/dev/null | jq -r '
    (if type=="array" then . else [] end)
    | sort_by({CRITICAL:4, HIGH:3, MEDIUM:2, LOW:1}[(.Severity|ascii_upcase)] // 0)
    | reverse | (.[0].FindingID // "")'
}

scripts/find.sh — scan

scripts/find.sh
#!/usr/bin/env bash
#
# find.sh — STEP 1: scan one file with CodeMender and record the findings.
# State (findings DB) is written to the gcsfuse-mounted bucket so the verify
# and fix steps can read it.
source "$(dirname "$0")/cm-env.sh"

: "${SCAN_PATH:?set SCAN_PATH (file to scan, relative to the repo)}"
cd "$REPO_DIR"

echo "🔍 cm find ${SCAN_PATH}"
cm find "${SCAN_PATH}" -y

echo "── findings ──────────────────────────────────────────"
cm report                                   # human-readable table in the log
cm report -f json > "$WORKSPACE/cm-findings.json" || echo 'null' > "$WORKSPACE/cm-findings.json"

FID=$(top_finding_id)
if [[ -z "$FID" ]]; then
  echo "No findings — the verify/fix steps will no-op."
else
  echo "Top finding selected for verify/fix: $FID"
fi

scripts/verify.sh — verify (may take minutes)

Runs CodeMender's full verification. We deliberately don't short-circuit it: a verified finding is a stronger signal than a raw scan hit.

scripts/verify.sh
#!/usr/bin/env bash
#
# verify.sh — STEP 2: verify the highest-severity finding.
#
# This runs CodeMender's FULL verification: it may build and run the target
# app to reproduce the exploit, so it can take several minutes. That is exactly
# why the CI image ships with node/npm and build tools. We do NOT short-circuit
# it — a verified finding is a stronger signal than a raw scan hit.
source "$(dirname "$0")/cm-env.sh"
cd "$REPO_DIR"

FID=$(top_finding_id)
if [[ -z "$FID" ]]; then
  echo "No findings to verify."; exit 0
fi

echo "🔬 cm find verify ${FID}"
cm find verify "${FID}"

echo "── status after verification ─────────────────────────"
cm report

scripts/fix.sh — fix & apply deterministically

Generates the patch, then applies CodeMender's stored patch (from cm report --patches) so the result is deterministic even when the fix agent resets the tree after testing its own patch.

scripts/fix.sh
#!/usr/bin/env bash
#
# fix.sh — STEP 3: generate the fix for the highest-severity finding, then apply
# CodeMender's stored patch deterministically and leave metadata for open-pr.
source "$(dirname "$0")/cm-env.sh"
cd "$REPO_DIR"

FID=$(top_finding_id)
if [[ -z "$FID" ]]; then
  echo "No findings to fix."; echo "NONE" > "$WORKSPACE/cm-status.txt"; exit 0
fi

# Grab finding metadata for the PR body NOW (before cm fix touches the tree).
cm report -f json | jq --arg id "$FID" '.[]|select(.FindingID==$id)' > "$WORKSPACE/cm-finding.json"
STATUS=$(jq -r '.Status' "$WORKSPACE/cm-finding.json")
FILE=$(jq -r '.FilePath' "$WORKSPACE/cm-finding.json" | sed "s#^$REPO_DIR/##")
if [[ "$STATUS" == "DISMISSED" ]]; then
  echo "Finding $FID was DISMISSED during verification — not fixing."
  echo "DISMISSED" > "$WORKSPACE/cm-status.txt"; exit 0
fi

echo "🩹 cm fix ${FID} --auto-apply -y"
cm fix "${FID}" --auto-apply -y 2>&1 | tee "$WORKSPACE/cm-fix.out"

# CodeMender prints a one-line headline tagged [Summary]; capture the last one.
SUMMARY=$(grep -aoE '\[Summary\][[:space:]]+.*' "$WORKSPACE/cm-fix.out" | tail -1 | sed -E 's/^\[Summary\][[:space:]]+//')
[[ -z "$SUMMARY" ]] && SUMMARY="Applied CodeMender remediation for finding ${FID}."
printf '%s\n' "$SUMMARY" > "$WORKSPACE/cm-summary.txt"

# The fix agent may or may not leave its patch applied (it sometimes builds/runs
# the app to test the fix and then resets the tree). To be deterministic we
# reset the tree to HEAD and apply CodeMender's STORED patch as the single
# source of truth, extracted from `cm report --patches`.
git checkout -- . 2>/dev/null || true
rm -rf "$REPO_DIR"/.exploit "$REPO_DIR"/.cm_project "$REPO_DIR"/routes/.exploit 2>/dev/null || true

cm report --patches 2>/dev/null | awk '
  /^  diff --git /{cap=1}
  cap && /^  /{print; next}
  cap && !/^  /{cap=0}
' | sed 's/^  //' > "$WORKSPACE/cm.patch"
sed -i -e :a -e '/^[[:space:]]*$/{$d;N;ba}' "$WORKSPACE/cm.patch"   # trim trailing blanks

if [[ ! -s "$WORKSPACE/cm.patch" ]]; then
  echo "❌ CodeMender produced no patch."; echo "NO_PATCH" > "$WORKSPACE/cm-status.txt"; exit 1
fi
git apply --whitespace=nowarn "$WORKSPACE/cm.patch"

if git diff --quiet -- "$FILE"; then
  echo "❌ patch did not change ${FILE}."; echo "NO_CHANGE" > "$WORKSPACE/cm-status.txt"; exit 1
fi

echo "FIXED" > "$WORKSPACE/cm-status.txt"
echo "✅ Fix applied to ${FILE}"
git --no-pager diff -- "$FILE"

scripts/open-pr.sh — commit, push, open PR

scripts/open-pr.sh
#!/usr/bin/env bash
#
# open-pr.sh — STEP 4: commit CodeMender's fix to a branch, push it to the
# fork, and open a Pull Request whose body is CodeMender's own analysis.
#
# Does NOT touch CodeMender state — only git + the GitHub API — so it needs no
# gcsfuse mount. Reads the metadata that fix.sh left in $WORKSPACE.
set -euo pipefail

WORKSPACE="${WORKSPACE:-/workspace}"
REPO_DIR="${REPO_DIR:-$WORKSPACE/repo}"
BASE_BRANCH="${BASE_BRANCH:-master}"
GIT_AUTHOR_NAME="${GIT_AUTHOR_NAME:-codemender-bot}"
GIT_AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL:-codemender-bot@users.noreply.github.com}"

: "${GH_OWNER:?set GH_OWNER}"; : "${GH_REPO:?set GH_REPO}"; : "${GH_TOKEN:?set GH_TOKEN (from Secret Manager)}"
# Secret values often carry a trailing newline; strip all whitespace so it
# can't corrupt the push URL or the auth header.
GH_TOKEN=$(printf '%s' "$GH_TOKEN" | tr -d '[:space:]')

STATUS=$(cat "$WORKSPACE/cm-status.txt" 2>/dev/null || echo UNKNOWN)
if [[ "$STATUS" != "FIXED" ]]; then
  echo "cm-status is '$STATUS' (not FIXED) — nothing to open a PR for."; exit 0
fi

# --- Read the finding metadata ----------------------------------------------
F="$WORKSPACE/cm-finding.json"
FID=$(jq -r '.FindingID' "$F")
TITLE=$(jq -r '.Title' "$F")
SEV=$(jq -r '.Severity|ascii_upcase' "$F")
CWE=$(jq -r '.VulnID // "CWE-unknown"' "$F")
VTYPE=$(jq -r '.VulnType // "Vulnerability"' "$F")
CONF=$(jq -r '.Confidence // 0' "$F")
FILE=$(jq -r '.FilePath' "$F" | sed "s#^$REPO_DIR/##")
ANALYSIS=$(jq -r '.Analysis // ""' "$F")
SUMMARY=$(cat "$WORKSPACE/cm-summary.txt")

SHORT="${FID:0:8}"
CWE_SLUG=$(echo "$CWE" | tr 'A-Z' 'a-z' | tr -cd 'a-z0-9-')
BRANCH="codemender/${CWE_SLUG}-${SHORT}"
PR_TITLE="security: fix ${VTYPE} in ${FILE} (${CWE})"

# --- Commit ------------------------------------------------------------------
cd "$REPO_DIR"
git config user.name  "$GIT_AUTHOR_NAME"
git config user.email "$GIT_AUTHOR_EMAIL"
git checkout -b "$BRANCH"
git add -u                                   # tracked modifications only
git commit -F - <<EOF
${PR_TITLE}

${SUMMARY}

Finding:  ${TITLE}
Severity: ${SEV}   Confidence: ${CONF}%   ${CWE}
Detected & remediated by CodeMender (finding ${FID}).
EOF

# --- Push (token redacted from any output) ----------------------------------
PUSH_URL="https://x-access-token:${GH_TOKEN}@github.com/${GH_OWNER}/${GH_REPO}.git"
set +e
PUSH_OUT=$(git push "$PUSH_URL" "$BRANCH" 2>&1); PUSH_RC=$?
set -e
echo "${PUSH_OUT//${GH_TOKEN}/***}"
[[ $PUSH_RC -eq 0 ]] || { echo "❌ git push failed"; exit 1; }

# --- Compose the PR body -----------------------------------------------------
{
  echo "## 🛡️ CodeMender automated security fix"
  echo
  echo "> ${SUMMARY}"
  echo
  echo "| | |"
  echo "|---|---|"
  echo "| **Finding** | ${TITLE} |"
  echo "| **Severity** | ${SEV} |"
  echo "| **Type** | ${VTYPE} (${CWE}) |"
  echo "| **Confidence** | ${CONF}% |"
  echo "| **File** | \`${FILE}\` |"
  echo
  echo "### Analysis"
  echo
  echo "${ANALYSIS}"
  echo
  echo "### Change"
  echo '```diff'
  git --no-pager diff "${BASE_BRANCH}..${BRANCH}" -- "$FILE"
  echo '```'
  echo
  echo "---"
  echo "_Generated by CodeMender. Review the diff before approving; merging applies the fix to \`${BASE_BRANCH}\`._"
} > "$WORKSPACE/cm-pr-body.md"

# --- Open the PR -------------------------------------------------------------
jq -n --arg t "$PR_TITLE" --arg h "$BRANCH" --arg b "$BASE_BRANCH" \
      --rawfile body "$WORKSPACE/cm-pr-body.md" \
      '{title:$t, head:$h, base:$b, body:$body, maintainer_can_modify:true}' > "$WORKSPACE/pr-request.json"

HTTP=$(curl -sS -o "$WORKSPACE/pr-response.json" -w '%{http_code}' -X POST \
  -H "Authorization: Bearer ${GH_TOKEN}" \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  "https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/pulls" \
  -d @"$WORKSPACE/pr-request.json")

if [[ "$HTTP" == "201" ]]; then
  echo "✅ Pull Request opened: $(jq -r '.html_url' "$WORKSPACE/pr-response.json")"
elif [[ "$HTTP" == "422" ]]; then
  echo "ℹ️  A PR for this branch already exists (HTTP 422):"
  jq -r '.errors[]?.message // .message' "$WORKSPACE/pr-response.json"
else
  echo "❌ GitHub API returned HTTP $HTTP"
  jq -r '.message' "$WORKSPACE/pr-response.json" 2>/dev/null || cat "$WORKSPACE/pr-response.json"
  exit 1
fi
8

Write the Cloud Build pipeline

Alongside scripts/, create cloudbuild.yaml. It runs the five stages as separate steps; the CodeMender steps share state through the gcsfuse-mounted bucket, and open-pr reads the token from Secret Manager.

cloudbuild.yaml
# CodeMender remediation pipeline — SEPARATE find / verify / fix / open-pr steps.
#
# Run from a working directory that contains this file and the scripts/ folder
# (the source is uploaded, so the steps can run scripts/*.sh):
#
#   gcloud builds submit . \
#     --config cloudbuild.yaml \
#     --substitutions=\
#   _CM_IMAGE=us-central1-docker.pkg.dev/CENTRAL_PROJECT/codemender/codemender-ci:v0.2.0,\
#   _CM_STATE_BUCKET=YOUR_PROJECT-cm-state,\
#   _GH_OWNER=YOUR_GITHUB_USERNAME,_GH_REPO=juice-shop,_BASE_BRANCH=master,\
#   _SCAN_PATH=routes/login.ts
#
# CodeMender state (config, identity, findings SQLite DB) is persisted across
# the separate steps in a GCS bucket mounted with gcsfuse (see scripts/cm-env.sh).
# The GitHub token comes from Secret Manager (secret name: gh-pat).
#
# Set _CM_IMAGE, _CM_STATE_BUCKET and _GH_OWNER (on the command line in Step 9,
# or here). The rest have working defaults for this lab.
substitutions:
  _CM_IMAGE: ""              # ◀ SET: CodeMender image URL from your facilitator
  _CM_STATE_BUCKET: ""       # ◀ SET: the GCS state bucket you created (Step 4)
  _GH_OWNER: ""              # ◀ SET: your GitHub username (owner of the fork)
  _GH_REPO: juice-shop
  _BASE_BRANCH: master
  _SCAN_PATH: routes/login.ts

steps:
  # 0 · Clone your fork into the shared /workspace (scripts/ stays alongside).
  - id: clone
    name: gcr.io/cloud-builders/git
    entrypoint: bash
    args:
      - -c
      - |
        set -e
        rm -rf /workspace/repo
        git clone --depth 1 --branch "${_BASE_BRANCH}" \
          "https://github.com/${_GH_OWNER}/${_GH_REPO}.git" /workspace/repo

  # 1 · Scan (writes findings to the gcsfuse-mounted state bucket).
  - id: find
    name: ${_CM_IMAGE}
    entrypoint: bash
    args: ["scripts/find.sh"]
    env: &cmenv
      - CM_STATE_BUCKET=${_CM_STATE_BUCKET}
      - STATE_ID=${BUILD_ID}
      - WORKSPACE=/workspace
      - REPO_DIR=/workspace/repo
      - SCAN_PATH=${_SCAN_PATH}

  # 2 · Verify the top finding (full verification — may take several minutes).
  - id: verify
    name: ${_CM_IMAGE}
    entrypoint: bash
    args: ["scripts/verify.sh"]
    env: *cmenv

  # 3 · Fix the top finding and stage commit/PR metadata.
  - id: fix
    name: ${_CM_IMAGE}
    entrypoint: bash
    args: ["scripts/fix.sh"]
    env: *cmenv

  # 4 · Commit, push the branch, and open the Pull Request.
  - id: open-pr
    name: ${_CM_IMAGE}
    entrypoint: bash
    args: ["scripts/open-pr.sh"]
    secretEnv: ["GH_TOKEN"]
    env:
      - WORKSPACE=/workspace
      - REPO_DIR=/workspace/repo
      - GH_OWNER=${_GH_OWNER}
      - GH_REPO=${_GH_REPO}
      - BASE_BRANCH=${_BASE_BRANCH}

availableSecrets:
  secretManager:
    - versionName: projects/$PROJECT_ID/secrets/gh-pat/versions/latest
      env: GH_TOKEN

options:
  logging: CLOUD_LOGGING_ONLY
timeout: 3600s      # verify can build & run the app to reproduce the exploit
9

Run the pipeline

ℹ Set three values

Paste your image URL (_CM_IMAGE, from Step / your facilitator), state bucket (_CM_STATE_BUCKET, Step 4), and GitHub username (_GH_OWNER) into the command below. The rest have working defaults.

Run this from the directory containing cloudbuild.yaml and scripts/ (the source is uploaded so the steps can run scripts/*.sh):

bash
export CM_IMAGE="...from your facilitator.../codemender-ci:v0.2.0"

gcloud builds submit . \
  --config cloudbuild.yaml \
  --substitutions=_CM_IMAGE="$CM_IMAGE",_CM_STATE_BUCKET="$CM_STATE_BUCKET",_GH_OWNER=YOUR_GITHUB_USERNAME,_GH_REPO=juice-shop,_BASE_BRANCH=master,_SCAN_PATH=routes/login.ts
⏱ verify is the long pole

Expect 15–20 minutes total. The verify step builds & runs Juice Shop to reproduce the exploit — that's the point, and why the image carries node/npm.

10

Review the fix & the PR

CodeMender flags the classic Juice Shop login SQL injection and fixes it with a parameterized query:

SeverityTypeFile
CriticalSQL Injection · CWE-89routes/login.ts
routes/login.ts · CodeMender patch
- models.sequelize.query(`SELECT * FROM Users WHERE email = '${req.body.email || ''}'
-   AND password = '${security.hash(req.body.password || '')}' AND deletedAt IS NULL`,
-   { model: UserModel, plain: true })
+ models.sequelize.query('SELECT * FROM Users WHERE email = ? AND password = ?
+   AND deletedAt IS NULL',
+   { replacements: [req.body.email || '', security.hash(req.body.password || '')],
+     model: UserModel, plain: true })

The PR body is CodeMender's own write-up — summary, severity, CWE, and full analysis. Find it in the build log (Pull Request opened: …) or with gh pr list.

11

Approve & merge the Pull Request

You are the human in the loop. Read the diff, then approve and merge — merging is what lands the fix on master.

bash · or use the GitHub UI
gh pr view N  --repo YOUR_GITHUB_USERNAME/juice-shop --web
gh pr merge N --repo YOUR_GITHUB_USERNAME/juice-shop --squash
12

Confirm the remediation

Prove it worked: run the pipeline again against the now-merged master. The parameterized query is no longer injectable, so there's no Critical SQL injection to fix — the fix step finds nothing to do and opens no PR.

bash
gcloud builds submit . --config cloudbuild.yaml \
  --substitutions=_CM_IMAGE="$CM_IMAGE",_CM_STATE_BUCKET="$CM_STATE_BUCKET",_GH_OWNER=YOUR_GITHUB_USERNAME,_GH_REPO=juice-shop,_BASE_BRANCH=master,_SCAN_PATH=routes/login.ts
✅ You closed the loop

Package → find → verify → fix → PR → human approval → merge → re-scan clean. A complete AI-assisted remediation workflow, with the approval gate intact.

Go furtherBonus challenges

  1. Remediate a second bug. Set _SCAN_PATH=routes/search.ts and rerun — CodeMender finds and fixes a UNION-based SQL injection.
  2. Inspect the state. After a run, browse gs://$CM_STATE_BUCKET/<build-id>/.codemender/ — you'll see state.db, identity.key, and session logs that gcsfuse persisted between steps.
  3. Enforce build-after-fix. Set build.command in cm-env.sh's config so cm build compiles the project after a fix.
  4. Add a trigger. Replace the manual submit with a Cloud Build trigger on a schedule or push.

ReferenceCodeMender command cheat sheet

CommandWhat it does
cm initCreate ~/.codemender/ (config, state DB, identity key)
cm find <path>Scan for vulnerabilities
cm find verify <id>Reproduce/triage a finding → Verified / Dismissed
cm fix <id> --auto-apply -yGenerate + apply a patch, print a summary
cm report -f json|sarifList findings; --patches shows the diffs
cm vcs diff / statusShow workspace changes

When something breaksTroubleshooting

SymptomFix
denied … artifactregistry pulling the imageFacilitator must grant your CB SA(s) artifactregistry.reader (Part A3).
PermissionDenied: Secret … gh-patGrant secretmanager.secretAccessor to your CB SA(s) (Step 6).
gcsfuse mount / bucket permission errorGrant storage.objectAdmin on the bucket to your CB SA(s) (Step 6).
Build fails with a service-account / logging errorGrant roles/logging.logWriter (and, if required, roles/cloudbuild.builds.builder) to the CB SA.
PR step returns HTTP 422A PR for that branch already exists — merge/close it, then rerun.
Pipeline says No findingsTarget already fixed, or wrong path. Confirm _SCAN_PATH exists on _BASE_BRANCH.

Beyond the labProduction hardening & EAP caveats

⚠ This CodeMender build is Early Access (EAP)

A few rough edges are worked around in the scripts and will smooth out as the product matures — don't treat them as permanent design.

  • cm fix --export is not implemented, and the fix agent's exploit-testing path can reset the working tree — so fix.sh re-applies the patch it reads back from cm report --patches. Expect to delete that block once fix reliably leaves or exports the patch.
  • Verification is the long pole (~10–15 min): it builds and runs the whole app. Budget for it, or cache node_modules / the built app to speed reruns.
  • Exit codes are still stabilising, so the scripts re-read cm report between phases instead of trusting a phase's exit status.
  • The credential is baked into the binary, so the image is sensitive — keep it in an IAM-gated registry, never public.

Best practices for a real deployment

  • Feed a standard sink. Emit cm report -f sarif into Security Command Center or GitHub code scanning instead of parsing tables — findings become trackable, not just log output.
  • Trigger, don't submit. Wire a Cloud Build trigger to the fork so scans run automatically on push/PR and Cloud Build checks out the source for you (no clone step).
  • Gate on a build. Set build.command so cm build compiles & tests after a fix and rejects patches that don't build.
  • Harden identity & secrets. Pin the image by digest, use least-privilege service accounts, rotate the GitHub token, and prefer a fine-grained PAT (or a GitHub App) over a broad token.
  • Structure the orchestration. Once you outgrow the lab, replace the bash glue with a small typed orchestrator (e.g. Python) for retries and structured error handling.
  • Widen coverage. Scan directories or the whole service, and schedule periodic runs rather than one file on demand.

Leave it tidyCleanup

bash
gcloud secrets delete gh-pat --quiet
gcloud storage rm -r "gs://$CM_STATE_BUCKET" --quiet     # optional: delete CM state
gh pr list --repo YOUR_GITHUB_USERNAME/juice-shop     # close any leftover PRs

Facilitators: keep the Artifact Registry image for the next cohort, or delete it with gcloud artifacts repositories delete codemender --location=$REGION.