Google Cloud Build provider
Parses cloudbuild.yaml on disk, no Google Cloud credentials, no
gcloud install, no Cloud Build API token required. Each document
must declare a top-level steps: list; files without it (SAM
templates, ordinary YAML configs) are skipped by the loader.
Producer workflow
# --cloudbuild-path is auto-detected when cloudbuild.yaml/cloudbuild.yml
# exists at cwd.
pipeline_check --pipeline cloudbuild
# …or pass it explicitly.
pipeline_check --pipeline cloudbuild --cloudbuild-path ci/cloudbuild.yaml
All other flags (--output, --severity-threshold, --checks,
--standard, …) behave the same as with the other providers.
Cloud Build-specific checks
Several checks target Cloud Build concepts that have no direct analogue in other providers:
- GCB-002,
serviceAccount:must be set; the default Cloud Build SA is typically broader than any single pipeline needs. - GCB-003, secrets must flow through
availableSecrets.secret Manager[].env+secretEnv:, never via inlinegcloud secrets versions accessinargs. - GCB-004,
options.dynamicSubstitutions: truecombined with a user-substitution ($_FOO) in step args opens a trigger-editor- controlled shell-injection path.
What it covers
26 checks · 7 have an autofix patch (--fix).
| Check | Title | Severity | Fix |
|---|---|---|---|
| GCB-001 | Cloud Build step image not pinned by digest | HIGH | 🔧 fix |
| GCB-002 | Cloud Build uses the default service account | HIGH | |
| GCB-003 | Secret Manager value referenced in step args | HIGH | |
| GCB-004 | dynamicSubstitutions on with user substitutions in step args | HIGH | |
| GCB-005 | Build timeout unset or excessive | LOW | 🔧 fix |
| GCB-006 | Dangerous shell idiom (eval, sh -c variable, backtick exec) | HIGH | |
| GCB-007 | availableSecrets references versions/latest |
MEDIUM | 🔧 fix |
| GCB-008 | No vulnerability scanning step in Cloud Build pipeline | MEDIUM | |
| GCB-009 | Artifacts not signed (no cosign / sigstore step) | MEDIUM | |
| GCB-010 | Remote script piped to shell interpreter | HIGH | |
| GCB-011 | TLS / certificate verification bypass | HIGH | 🔧 fix |
| GCB-012 | Credential-shaped literal in pipeline body | CRITICAL | |
| GCB-013 | Package install bypasses registry integrity (git / path / tarball) | MEDIUM | |
| GCB-014 | Build logging disabled (options.logging: NONE) | HIGH | 🔧 fix |
| GCB-015 | SBOM not produced (no CycloneDX / syft / Trivy-SBOM step) | MEDIUM | |
| GCB-016 | Step dir field contains parent-directory escape (..) | MEDIUM | |
| GCB-017 | Image-producing build does not request SLSA provenance | MEDIUM | |
| GCB-018 | Legacy KMS secrets block in use (prefer availableSecrets / Secret Manager) | MEDIUM | |
| GCB-019 | Shell entrypoint inlines a user substitution into args | HIGH | |
| GCB-020 | serviceAccount points at the default Cloud Build service account | HIGH | |
| GCB-021 | No private worker pool, build runs on the shared default pool | MEDIUM | 🔧 fix |
| GCB-022 | options.substitutionOption set to ALLOW_LOOSE | LOW | 🔧 fix |
| GCB-023 | Step references a user substitution not declared in substitutions: | MEDIUM | |
| GCB-024 | Build pushes Docker images but top-level images: is empty | LOW | |
| GCB-025 | Build has no tags for audit / discoverability | LOW | |
| GCB-026 | Step waitFor: references an unknown step id | MEDIUM |
GCB-001: Cloud Build step image not pinned by digest
Bare references (gcr.io/cloud-builders/docker) are treated as :latest by Cloud Build. Tag-only references (:20, :latest) count as unpinned. Only @sha256:… suffixes pass.
Recommended action
Pin every steps[].name image to an @sha256:<digest> suffix. gcr.io/cloud-builders/docker:latest is mutable; Google publishes new builder images frequently and the next build would pull whatever is current. Resolve the digest with gcloud artifacts docker images describe <ref> --format='value(image_summary.digest)' and pin it.
GCB-002: Cloud Build uses the default service account
The default Cloud Build service account historically held roles/cloudbuild.builds.builder plus project-level editor in many organisations. Even under the GCP April-2024 default-identity change, the default SA is still broader than what a single pipeline needs. Explicit serviceAccount: is required to pass.
Recommended action
Create a dedicated service account for the build, grant it only the roles the pipeline actually needs (roles/artifactregistry.writer, roles/storage.objectCreator for artifact upload, etc.), and set serviceAccount: projects/<PROJECT>/serviceAccounts/<NAME>@.... Leaving it unset falls back to the default Cloud Build SA, which accumulates roles over a project's lifetime and is routinely granted roles/editor.
GCB-003: Secret Manager value referenced in step args
Detection patterns: literal projects/<n>/secrets/<name>/versions/... URIs, gcloud secrets versions access shell invocations, and $(gcloud secrets …) command substitutions in step args or entrypoint.
Recommended action
Map the secret under availableSecrets.secretManager[] with an env: alias, then reference it from each step via secretEnv: [ALIAS]. Avoid inline gcloud secrets versions access in args, the resolved plaintext lands in build logs.
GCB-004: dynamicSubstitutions on with user substitutions in step args
The _-prefix is Cloud Build's naming convention for user substitutions; they are editable via build trigger UI, gcloud builds submit --substitutions, and the REST API. Built-in substitutions ($PROJECT_ID, $COMMIT_SHA, $BUILD_ID) are derived from the trigger event and are not treated as user-controlled by this rule.
Recommended action
Either disable options.dynamicSubstitutions (it defaults to false) or move user substitutions ($_FOO) out of step args, pass them through env: and reference them inside a shell script the builder runs. Dynamic substitution re-evaluates bash syntax after variable expansion, giving trigger-config editors a script-injection channel.
GCB-005: Build timeout unset or excessive
Cloud Build's default 10-minute timeout applies silently when timeout: is absent. Accepted format is <N>s (seconds); <N>m/<N>h forms are a gcloud convenience and are treated as malformed by the API.
Recommended action
Declare an explicit timeout: at the top of cloudbuild.yaml bounded to the build's realistic worst case (e.g. 1800s for most container builds). Explicit bounds shorten the window a compromised build can spend on a shared worker and flag regressions when a legitimate step slows down.
GCB-006: Dangerous shell idiom (eval, sh -c variable, backtick exec)
Complements GCB-004 (dynamicSubstitutions + user substitution in args). GCB-006 fires on intrinsically risky shell idioms, eval, sh -c "$X", backtick exec, regardless of whether the substitution source is currently trusted.
Known false-positive modes
eval "$(ssh-agent -s)"and similareval "$(<literal-tool>)"bootstrap idioms are intentionally NOT flagged, the substituted command is literal, only its output is eval'd.
Recommended action
Replace eval "$VAR" / sh -c "$VAR" / backtick exec with direct command invocation. Validate or allow-list any value that must feed a dynamic command at the boundary. In Cloud Build these idioms typically appear in args: [-c, ...] entries under a bash entrypoint.
GCB-007: availableSecrets references versions/latest
versions/latest is documented as a rolling alias. A build run on Monday and a re-run on Tuesday can consume different secret bodies without any change to cloudbuild.yaml, breaking the reproducibility invariant that pinning protects.
Recommended action
Pin each availableSecrets.secretManager[].versionName to a specific version number (.../versions/7) rather than latest. Rotate by updating the number when a new version is promoted, not by silently publishing a new version that the next build pulls.
GCB-008: No vulnerability scanning step in Cloud Build pipeline
The detector matches tool names anywhere in the document, step images, args, or entrypoint strings. Container Analysis API scanning configured at the project level counts as compensating control but is out of scope for this YAML-only check; if you rely on it, suppress this rule via --checks.
Recommended action
Add a step that runs a vulnerability scanner, trivy, grype, snyk test, npm audit, pip-audit, osv-scanner, or govulncheck. In Cloud Build this typically looks like a step with name: aquasec/trivy or an entrypoint: bash step that invokes trivy image / grype <ref> on the built image.
GCB-009: Artifacts not signed (no cosign / sigstore step)
Silent-pass when the pipeline does not appear to produce artifacts (no docker push / gcloud run deploy / kubectl apply / etc. in any step). The detector matches cosign, sigstore, slsa-framework, and notation.
Recommended action
Add a signing step before images: is resolved, for example, a step with name: gcr.io/projectsigstore/cosign that runs cosign sign --yes <registry>/<repo>@<digest>. Pair with an attestation step (cosign attest --predicate sbom.json --type cyclonedx) so consumers can verify both the signature and the build provenance.
GCB-010: Remote script piped to shell interpreter
Detects curl | bash, wget | sh, bash -c "$(curl …)", inline python -c urllib.urlopen, curl > x.sh && bash x.sh, and PowerShell irm | iex idioms. Vendor-trusted hosts (rustup.rs, get.docker.com, sdk.cloud.google.com, …) are still flagged at HIGH but the hit carries a vendor_trusted marker so dashboards can stratify known-vendor installers from arbitrary attacker URLs.
Recommended action
Download the script to a file, verify its checksum, then execute it. Or vendor the script into the repository and invoke it from the checkout, removing the network fetch removes the attacker-controllable content entirely.
GCB-011: TLS / certificate verification bypass
Covers curl -k / wget --no-check-certificate, git config http.sslVerify false, NODE_TLS_REJECT_UNAUTHORIZED=0, npm config set strict-ssl false, PYTHONHTTPSVERIFY=0, GOINSECURE=, helm --insecure-skip-tls-verify, kubectl --insecure-skip-tls-verify, and ssh -o StrictHostKeyChecking=no.
Recommended action
Fix the underlying certificate issue, install the correct CA bundle into the step image, or point the tool at a mirror that presents a valid chain. Disabling verification trades a build error for a silent MITM window.
GCB-012: Credential-shaped literal in pipeline body
Complements GCB-003 (inline gcloud secrets versions access) and GCB-007 (/versions/latest alias). This rule runs the shared credential-shape catalog against every string in the YAML. AWS keys, GitHub PATs, Slack webhooks, JWTs, PEM private key blocks, and any user-registered --secret-pattern regex. Known placeholders like EXAMPLE/CHANGEME are already filtered upstream so fixtures and docs don't false-match.
Recommended action
Rotate the exposed credential immediately. Move the value to availableSecrets.secretManager and reference it via secretEnv: so the plaintext never lands in the YAML or the build logs. For cloud access prefer workload-identity federation over long-lived keys.
GCB-013: Package install bypasses registry integrity (git / path / tarball)
Complements GCB-012 (literal secrets) and GCB-010 (curl-pipe). Where those catch attacker content at fetch time, this rule catches installs that silently bypass the lockfile/registry integrity model, the build is technically reproducible but the source of truth is whatever the git ref / filesystem / tarball URL served most recently.
Recommended action
Pin git dependencies to a commit SHA (pip install git+https://…/repo@<sha>, cargo install --git … --rev <sha>). Publish private packages to Artifact Registry (or another internal registry) instead of installing from a filesystem path or tarball URL.
GCB-014: Build logging disabled (options.logging: NONE)
options.logging defaults to CLOUD_LOGGING_ONLY when omitted, which passes. Only the explicit NONE value (case- insensitive) trips this rule. GCS_ONLY / LEGACY pass. They persist logs, just to a different destination.
Recommended action
Remove the logging: NONE override, or replace it with CLOUD_LOGGING_ONLY / GCS_ONLY, so every step's stdout, stderr, and exit code is persisted. Loss of logs is a detection-and-response black hole; the storage cost is measured in cents.
GCB-015: SBOM not produced (no CycloneDX / syft / Trivy-SBOM step)
Complements GCB-009 (signing) and GCB-008 (vuln scanning). Without an SBOM, downstream consumers cannot audit the exact dependency set shipped in a Cloud Build image, delaying vulnerability response when a transitive dep is disclosed. Pairs naturally with cosign attest --type cyclonedx in a follow-up step.
Recommended action
Add an SBOM generation step, syft <image> -o cyclonedx-json, trivy image --format cyclonedx, and publish the resulting document alongside the image (typically via a cosign attestation so the SBOM travels with the artifact).
GCB-016: Step dir field contains parent-directory escape (..)
Cloud Build doesn't sandbox the dir: value beyond a join against /workspace. dir: ../etc resolves to /etc inside the builder container, which is rarely the intent. The check fires on any literal .. segment; single-dot ./ and absolute paths are fine.
Recommended action
Replace .. traversals in dir: with absolute paths rooted under /workspace (e.g. dir: /workspace/sub) or split the work across multiple steps that each set dir: to an exact subdirectory. The Cloud Build worker starts each step with the workspace mounted at /workspace; a .. escape from there reaches the builder image's root filesystem and any credentials the image carries.
GCB-017: Image-producing build does not request SLSA provenance
SLSA Build Level 2 requires that the build platform produce signed provenance. Cloud Build's VERIFIED verify option is the documented way to opt in. The check is silent when the build does not produce an image (no top-level images: and no docker push / gcloud run deploy style steps); for those, signing and provenance aren't applicable.
Recommended action
Set options.requestedVerifyOption: VERIFIED on builds that publish container images. Cloud Build then emits a signed SLSA provenance attestation alongside the image, which downstream verifiers (Binary Authorization, cosign verify-attestation, gcloud artifacts docker images describe) can use to check that an image was built by the configured pipeline rather than smuggled in from elsewhere.
GCB-018: Legacy KMS secrets block in use (prefer availableSecrets / Secret Manager)
Cloud Build supports two secret-injection mechanisms. The older secrets: block carries KMS-encrypted ciphertext directly in the YAML; the cipher is decrypted at build time if the build's service account has cloudkms.cryptoKeyDecrypter on the key. The newer availableSecrets block references Secret Manager versions by URL, which is the documented modern approach. The legacy form still works, but rotating a value means re-encrypting and committing a new ciphertext.
Known false-positive modes
- Builds that use both forms during a migration trip the rule on the legacy block. That's intentional, finishing the migration is the fix.
Recommended action
Migrate from the top-level secrets: block (KMS-encrypted values stored inline in the YAML) to availableSecrets + Secret Manager. Replace each secrets[].secretEnv mapping with a versionName reference under availableSecrets.secretManager. Secret Manager rotates without re-encrypting and re-committing the YAML, scopes access via IAM rather than the KMS key's IAM, and produces an explicit audit log entry on every read.
GCB-019: Shell entrypoint inlines a user substitution into args
Distinct from GCB-004, which fires only when options.dynamicSubstitutions: true re-evaluates bash syntax after expansion. GCB-019 fires whenever a step uses a shell as its entrypoint AND a $_USER_VAR token lands inside args: Cloud Build expands the substitution before the step runs, and the shell then interprets any metacharacters the substitution carried, straight command injection through trigger configuration.
Recommended action
Pass user substitutions through env: (or secretEnv: for sensitive values) and reference them inside a checked-in shell script rather than splicing them directly into args. If the step truly needs to invoke shell logic inline, switch the entrypoint to the underlying tool (docker, gcloud, gsutil) and let the tool see the substitution as an argument, not as shell text.
GCB-020: serviceAccount points at the default Cloud Build service account
Complements GCB-002, which only fires when serviceAccount: is unset. This rule fires when an explicit value is set but still resolves to the project default, typically the email shape <digits>@cloudbuild.gserviceaccount.com, optionally wrapped in the projects/<id>/serviceAccounts/... URI form. The April-2024 GCP default-identity change kept the same SA shape; the broad-permissions concern remains.
Known false-positive modes
- Single-pipeline GCP projects where the default SA's roles are actively scoped down. Rare in practice; create a named SA anyway so the audit log stays unambiguous about which pipeline made each API call.
Recommended action
Don't bind the build to <project-number>@cloudbuild.gserviceaccount.com. The default Cloud Build SA accumulates roles over a project's lifetime (commonly roles/editor or broad Artifact Registry / Secret Manager access). Create a dedicated SA per pipeline, grant only the roles the build actually needs, and reference it by its bespoke email (<name>@<project>.iam.gserviceaccount.com). Revoking a compromised pipeline then doesn't unbind every other build in the project.
GCB-021: No private worker pool, build runs on the shared default pool
Cloud Build runs in a shared Google-managed pool by default. Switching to a private worker pool is the prerequisite for every other network-perimeter control: egress restriction to specific peered networks, ingress blocking of public endpoints, and traffic interoperation with VPC Service Controls. Both options.pool.name and the legacy options.workerPool field are accepted.
Known false-positive modes
- OSS / sample / one-off builds that legitimately have no private network and no internal endpoints to protect. Suppress with a brief
.pipelinecheckignorerationale rather than disabling at the catalog level.
Recommended action
Set options.pool.name: projects/<PROJECT>/locations/<REGION>/workerPools/<NAME> to bind the build to a private worker pool inside your VPC. The default pool runs on a shared Google-managed network with public-internet egress and ingress paths Google chooses, which makes egress filtering, VPC-SC perimeters, and source-IP allowlists on internal endpoints impossible. A private pool also gives you the option to disable external IPs and to log the build's network activity through your own VPC flow logs.
GCB-022: options.substitutionOption set to ALLOW_LOOSE
Cloud Build accepts two values for options.substitutionOption: MUST_MATCH (default, any undefined $_VAR reference fails the build at parse time) and ALLOW_LOOSE (undefined references silently expand to ""). The default is the safer setting; this rule only fires on the explicit ALLOW_LOOSE opt-in. Builds that genuinely depend on optional substitutions should pass them through substitutions: defaults, not rely on silent empty-string fallback.
Known false-positive modes
- Migration scenarios where a long-running pipeline pre-dates MUST_MATCH and the operator needs ALLOW_LOOSE temporarily. Suppress with a brief
.pipelinecheckignorerationale and anexpires:date so the waiver doesn't outlive the migration.
Recommended action
Drop options.substitutionOption (the default is MUST_MATCH) or set it explicitly to MUST_MATCH. ALLOW_LOOSE makes Cloud Build expand undefined substitutions to the empty string instead of failing the build. That paper-overs typos ($_REGON instead of $_REGION), masks unset variables that should have tripped review, and combined with dynamicSubstitutions: true (GCB-004) it widens the command-injection surface by letting attacker-controlled substitution tokens collapse to empty strings inside shell commands.
GCB-023: Step references a user substitution not declared in substitutions:
Walks every step's args: / entrypoint: / env: / dir: / id: / waitFor: for $_NAME tokens (Cloud Build's user-substitution syntax is leading underscore + uppercase / digits / underscore) and cross-references against the top-level substitutions: mapping. Built-in substitutions ($PROJECT_ID, $REPO_NAME, $BRANCH_NAME, $TAG_NAME, $COMMIT_SHA, $SHORT_SHA, $REVISION_ID, $BUILD_ID, $LOCATION, $TRIGGER_NAME, $_HEAD_*, $_BASE_*, $_PR_NUMBER and the $_HEAD_REPO_URL family) are Cloud Build server-set and don't appear in substitutions:; the rule allow-lists them so they don't false-positive.
Recommended action
Add an entry for every $_USER_VAR referenced anywhere in the build to the top-level substitutions: block, either with a sensible default or with an empty string if the trigger always supplies the value. Cloud Build's default options.substitutionOption: MUST_MATCH then fails the build at parse time on undeclared references (catching typos at the gate). With the looser ALLOW_LOOSE opt-in (GCB-022) undeclared references silently expand to the empty string, which masks the bug and quietly broadens any shell command that interpolates the value.
GCB-024: Build pushes Docker images but top-level images: is empty
Walks step args / entrypoint / cmd looking for docker push (or the buildx imagetools push variant) invocations. When the build has at least one such step but the top-level images: field is missing or empty, fires. Steps that build and push via the gcr.io/cloud-builders/docker builder image are the common case; --push flags on buildx build are also detected. kaniko and buildah push idioms aren't currently detected. Those are different builder images entirely.
Known false-positive modes
- Multi-stage builds where one step pushes an intermediate image to a private cache registry and the final stage pushes the production artifact (which IS in
images:) would trip this rule on the cache push. Suppress with--ignore-filewhen this matches.
Recommended action
Add every image the build produces to the top-level images: array (e.g. images: ['gcr.io/$PROJECT_ID/myapp:$COMMIT_SHA']). Cloud Build then verifies the push succeeded before marking the build SUCCESS, records the image in the build's metadata for provenance / Binary Authorization attestation, and surfaces the image in the builds.list --image query. Without it, a push that happens inside a step is invisible to Cloud Build's tracking layer even though the image still lands in the registry.
GCB-025: Build has no tags for audit / discoverability
Cloud Build tags are user-defined labels attached to a build. They appear in the build's metadata (tags: field on the Build resource), in every Cloud Logging audit event for the build, and as a filter argument to gcloud builds list --filter='tags:<value>'. Substitution-bearing tags ($BRANCH_NAME, $COMMIT_SHA) count as populated. Cloud Build expands them at submission time.
Known false-positive modes
- Single-purpose project-local builds in a sandbox project may legitimately not need tags. Suppress with
--ignore-fileif that matches.
Recommended action
Add a top-level tags: array to every cloudbuild.yaml, at minimum, an environment tag (prod / staging / dev) and a service tag (backend / frontend / infra). Cloud Build records tags in the build metadata and Cloud Logging entries so post-incident triage of which build emitted this becomes a single gcloud builds list --filter='tags:prod' query. Without tags, builds discoverable only by build-id; the id is a UUID with no signal.
GCB-026: Step waitFor: references an unknown step id
Cloud Build's step dependency graph is built from each step's waitFor: array. A step with no waitFor: runs after all previous steps; a step with waitFor: ['-'] runs at the start of the build; a step with waitFor: ['<id>'] waits for the specific step. There's no validation that the referenced id exists, typo'd ids are silently treated like - (no-wait), so the dependency disappears without warning. This rule catches the silent-skip by walking every waitFor: value and cross-referencing it against the set of declared step ids.
Recommended action
Verify every ID listed in a step's waitFor: array matches an id: declared on a sibling step in the same build. The special token - (start at the beginning of the build, no dependencies) is the only non-id value Cloud Build accepts. A typo in waitFor: doesn't fail the build, Cloud Build silently skips the wait, so a step that was supposed to run after a setup step ends up running in parallel with it.
Adding a new Google Cloud Build check
- Create a new module at
pipeline_check/core/checks/cloudbuild/rules/gcbNNN_<name>.pyexporting a top-levelRULE = Rule(...)and acheck(path, doc) -> Findingfunction. The orchestrator auto-discoversRULEand callscheckwith the parsed YAML document. - Add a mapping for the new ID in
pipeline_check/core/standards/data/owasp_cicd_top_10.py(and any other standard that applies). - Drop unsafe/safe snippets at
tests/fixtures/per_check/cloudbuild/GCB-NNN.{unsafe,safe}.ymland add aCheckCaseentry intests/test_per_check_real_examples.py::CASES. - Regenerate this doc: