Security Multi-cloud

Integrate Wiz Code into GitHub Actions for IaC and Container Scanning Gates

A platform-engineering team ships a multi-cloud estate — Terraform for AWS and Azure, CloudFormation for a legacy account, Kubernetes manifests for the shared cluster, and a dozen container images on a common base — and the security team keeps finding the same things days after they merge: an S3 bucket that turned public in a refactor, a :latest base image carrying a Critical OpenSSL CVE, an IAM policy with Action: "*", a Kubernetes Deployment running privileged with hostNetwork: true, a Slack webhook token committed in a values file. The findings arrive in a weekly Wiz cloud report, by which point the misconfiguration is already running in production, already reachable from the internet, already sitting in the code-to-cloud graph as an attack path. The ask from the CISO is blunt: “stop merging the problems.” This guide wires Wiz Code — Wiz’s application-security-posture-management (ASPM) and code-security module — directly into GitHub Actions using the wiz-cli binary so that on every pull request the pipeline scans the infrastructure-as-code, the Dockerfiles, the freshly built image, the secrets in the working tree, and the SBOM, posts findings inline on the PR diff, and fails the required check when a Wiz policy is violated at Critical or High — so the fix happens before merge instead of after deploy.

This is an Advanced, implementation-first walkthrough, and the hands-on lab is the centerpiece: by the end you will have a reusable workflow that authenticates with a Wiz service account (client ID + secret, exchanged for a short-lived token), runs four scan types with real wiz-cli commands, enforces CI/CD policies whose exit codes are the gate, uploads SARIF so findings render as GitHub annotations, produces a CycloneDX SBOM artifact, and blocks the merge via branch protection. We will also place Wiz Code correctly in the real operating model: the code-to-cloud graph and Wiz Sensor runtime context that make a finding prioritized rather than just present, the baselining and ignore workflow so risk-accepted findings do not block forever, admission-style enforcement that carries the gate past CI into the cluster, and the identity, secrets, and ticketing systems that surround it.

Wiz Code is not a standalone scanner you bolt on and forget — its differentiator is that a finding in your Terraform is joined, in the same graph, to the running resource it becomes and the runtime signal from the Wiz Sensor, so the pipeline can tell you this Critical is an internet-exposed, publicly-reachable attack path versus this Critical is a theoretical CVE in a package that never loads. That context is why we fail on some Criticals and warn on others, and it is the throughline of everything below.

What problem this solves

Cloud misconfigurations and vulnerable images do not announce themselves at deploy time; they announce themselves weeks later, in a breach report or an agentless CSPM scan, long after the pull request that introduced them merged clean. The economic asymmetry is the whole argument for shift-left: a misconfiguration caught on a PR diff costs a developer thirty seconds and one commit; the same misconfiguration caught in production costs an incident bridge, a change-freeze, a rebuild, a redeploy, and — if it was reachable — a forensics engagement. Wiz’s own cloud scanning already finds these problems, but it finds them in the running estate, which means the misconfiguration is already live by the time it appears. The gap this guide closes is the window between “a developer wrote the problem” and “the problem is running”: we move detection to the pull request, where the cost of fixing is at its absolute minimum and the person who wrote it is still in context.

What breaks without a pre-merge gate: the security team runs a perpetual game of catch-up against a stream of merges they cannot see until deploy. They file tickets against code that already shipped; developers context-switch back to changes they made a sprint ago; the same class of finding recurs because nothing stops it at the source. Meanwhile the audit story is weak — “we scan the cloud” is not “we prevent the misconfiguration,” and a SOC 2 or FedRAMP assessor will ask which controls block insecure infrastructure from being provisioned, not which controls report on it after the fact.

Who hits this: any team with meaningful infrastructure-as-code and container velocity — a platform team merging dozens of Terraform PRs a week, an application team rebuilding images on every commit, a multi-cloud shop where the same base image and the same module patterns propagate a single mistake across many accounts. It bites hardest where IaC is the only way infrastructure changes (so the PR is the one chokepoint that governs the entire cloud) and where images are built from a shared base (so one bad base CVE lands in every service at once). The fix is not “scan more in the cloud”; it is “make the pull request the gate, and make the gate honest about what actually matters using the graph.”

To frame the whole field before the deep dive, here is every scan class this guide wires, what it inspects, what it catches, and where in the pipeline it runs:

Scan class wiz-cli verb Inspects Catches Runs at
IaC misconfiguration wizcli iac scan Terraform, CloudFormation, K8s YAML, ARM/Bicep, Helm, Dockerfile-as-IaC Public storage, wildcard IAM, unencrypted volumes, privileged pods, open security groups Before build, on the source tree
Container image (vuln) wizcli docker scan Built image layers, OS + language packages OS/library CVEs with fix status, malware, exposed secrets in layers After docker build
Dockerfile wizcli docker scan --dockerfile The Dockerfile statically :latest base pins, ADD from URL, hardcoded secrets, missing USER Before build, statically
Secrets wizcli dir scan / built into image scan Working-tree files, git history, image layers Verified cloud keys, tokens, private keys, connection strings Before build, on the tree
SBOM --sbom flags on image/dir scan Resolved dependency graph The full bill of materials (CycloneDX/SPDX) for later VEX/audit With the image or dir scan

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with GitHub Actions — jobs, steps, permissions, the pull_request trigger, and how a job’s exit status becomes a check. You should know your way around Terraform or another IaC format, docker build, and the basic shape of a container image (base layer, OS packages, application layer). You should understand OIDC at the level of “GitHub can mint a short-lived JWT that a trusted party exchanges for a credential” — this guide uses it to avoid a static Wiz secret in the repo. Familiarity with SARIF (the Static Analysis Results Interchange Format GitHub renders as annotations) and SBOM formats (CycloneDX, SPDX) helps but is not required.

Concretely, you need:

Requirement Why How to satisfy
Wiz tenant with Wiz Code enabled The module that provides CI/CD scanning, policies, and the graph Confirm in Wiz console → your subscription includes Code
Permission to create a Service Account The pipeline authenticates as a non-human identity Wiz Settings → Service Accounts (Admin or Project Admin role)
A GitHub repo with Actions enabled + admin To add the workflow and set branch protection Repo Settings → Actions on; admin to set required checks
A repo that already builds an image and has IaC The scans need real inputs Terraform/CFN/K8s in a dir; a working Dockerfile
The wizcli binary The scan engine on the runner Installed in-job (no manual step); pin a version for prod
A secrets source To supply the Wiz client ID/secret without hardcoding OIDC → Vault/GitHub OIDC, or (fallback) encrypted Actions secrets
(Optional) Wiz Sensor deployed Runtime context that prioritizes findings eBPF sensor on your clusters/hosts; not required for scanning

Where this fits: Wiz Code is the pre-merge, code-security layer of a defense-in-depth posture. Upstream of it sits the broader ASPM story and the cloud-posture (CSPM) scanning covered in Roll Out Wiz CSPM Across a Multi-Account AWS Organization with the AWS Connector — that connector scans the running estate; this guide scans the code that becomes it, and the two share one graph and one console. It pairs tightly with Shift-Left Testing and Quality Gates in CI/CD (Wiz Code is a security quality gate in exactly that model), with Secretless CI/CD: Workload Identity Federation for GitHub Actions and AKS for the OIDC credential story, and with Eliminating Secret Sprawl: Pipeline Scanning, Push Protection, and Leaked-Credential Remediation for the secrets half. On the artifact side it neighbours Deploy Harbor Registry on Kubernetes with Trivy Scanning, Replication, and Cosign Signing (registry-side scanning) and the SBOM-consumption side in Consuming the Software Supply Chain: SBOM Ingestion, VEX Triage, and Admission Verification. Downstream, once the artifact ships, the runtime baton passes to the Wiz Sensor and to Deploy CrowdStrike Falcon Sensor to Linux Fleets and Kubernetes via Helm DaemonSet.

Core concepts

Six mental models make every later decision obvious.

A finding is only as useful as its context, and the graph is the context. Every scanner on earth can tell you an S3 bucket resource has block_public_acls = false. What makes Wiz Code different is that the same finding is a node in the Wiz Security Graph, joined to the cloud resource that Terraform will become, the network exposure that resource has, the identity it can assume, the data it holds, and — if a Wiz Sensor is watching — whether the workload is actually running and reachable. So the pipeline can distinguish “public bucket with PII, internet-reachable, in the production account” (a genuine Critical attack path) from “public bucket in an isolated sandbox with nothing in it” (noise). This is why the gate policy is not “fail on all Criticals” but “fail on Criticals the graph rates as real.”

The service account is a non-human identity, and wizcli auth exchanges it for a short-lived token. Wiz issues a Client ID and Client Secret for a service account. wizcli auth --id <id> --secret <secret> exchanges those for a short-lived bearer token that the CLI caches for the duration of the job and uses for every scan. The client secret is the sensitive material — it should never sit in a workflow file, and ideally never sit as a static GitHub secret either; the OIDC pattern leases it at run time so nothing long-lived lives in the repo.

A CI/CD policy is the contract; the exit code is the enforcement. The decision of what fails the build lives in Wiz as a CI/CD policy — a named rule that says, for example, “fail on Critical and High IaC misconfigurations.” You pass --policy "<name>" to wizcli, the CLI evaluates the scan against that policy, and it exits non-zero when the policy is violated. That non-zero exit fails the GitHub step, fails the job, turns the required check red, and — with branch protection — blocks the merge. Security owns the policy centrally; developers cannot weaken it by editing YAML, because the threshold lives in Wiz, not the workflow.

Scan the Dockerfile and the built image — they catch different things. The Dockerfile scan is static: it reads the instructions and flags a :latest base pin, an ADD from a remote URL, a hardcoded secret in an ENV, a missing non-root USER, before any build happens. The image scan runs after docker build and inspects the resolved layers — the actual base image the tag pointed to, the packages apt-get/pip/npm pulled in, the OS CVEs that are genuinely present. A Dockerfile can look clean and still resolve to a base carrying a Critical CVE; an image can be built from a Dockerfile with an obvious ADD smell. Run both; skip neither.

Findings are stateful across runs — baselining is how you avoid alert fatigue. A gate that fires on every pre-existing finding on the first day is a gate the team disables by lunchtime. Wiz supports baselining (mark the current set of findings as the accepted starting point, and gate only on new findings a PR introduces) and ignore rules (a .wizignore file or a policy-level ignore, each with a reason and an expiry). This is what makes the gate adoptable on a brownfield repo: you draw a line, block regressions, and burn down the backlog on your own schedule — you do not block the whole team on day one for debt they did not create in this PR.

Pre-merge scanning is the first layer, not the only one. The gate stops the misconfiguration in the PR, but infrastructure drifts outside the pipeline (a console change, a break-glass edit, a resource created by another tool), and images that passed on Tuesday grow new CVEs on Wednesday as vulnerabilities are disclosed. So Wiz Code is paired with agentless cloud scanning (continuous CSPM of the running estate) and admission-style enforcement (a gate at deploy/admission time that re-checks the artifact), and with a runtime sensor for threat detection once the workload is live. Shift-left moves detection earlier; it does not replace the layers that watch what actually runs.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary at the end repeats these for lookup; this table is the mental model side by side:

Term One-line definition Where it lives Why it matters to the gate
Wiz Code Wiz’s ASPM / code-security module Wiz tenant Provides wizcli, policies, the code side of the graph
wizcli The scanner CLI on the runner Installed in-job Runs every scan; its exit code is the gate
Service account Non-human Wiz identity (client ID + secret) Wiz → Service Accounts How the pipeline authenticates, least-privileged
CI/CD policy Named rule deciding pass/fail by severity/class Wiz → Policies The contract; --policy invokes it
Security Graph The joined model of code + cloud + runtime Wiz backend Turns a raw finding into a prioritized attack path
Wiz Sensor eBPF runtime agent Your hosts/clusters Adds “is it actually running/reachable” context
SARIF Static-analysis result format Emitted by wizcli GitHub renders it as inline PR annotations
SBOM Bill of materials (CycloneDX/SPDX) Artifact of a scan Feeds VEX, audit, and later admission checks
Baseline The accepted starting set of findings Wiz / baseline file Gate on new findings only; brownfield adoption
.wizignore Per-repo documented exceptions Repo root Time-boxed, reasoned suppression of specific findings
Attack path A graph chain from exposure to impact Wiz Graph The unit of real prioritization, not raw severity
Admission gate Deploy-time re-check of the artifact K8s admission / CD stage Carries the gate past CI into the cluster

The wizcli command surface

Everything the pipeline does is a wizcli invocation, so learn the command surface before the YAML. The CLI is a single static binary; you install it, authenticate once, and run scans. Here is the top-level verb map:

Command Purpose Key inputs Primary output
wizcli version Print the CLI version (pin this in prod) Version string
wizcli auth Exchange client ID/secret for a session token --id, --secret Cached token for the job
wizcli iac scan Scan IaC files for misconfigurations --path, --policy, --output Findings + exit code
wizcli docker scan Scan a built image (or a Dockerfile) --image / --dockerfile, --policy Vulns/secrets + SBOM + exit code
wizcli dir scan Scan a directory (secrets, deps, SBOM) --path, --policy Secrets/vulns + exit code
wizcli docker tag Attach scan verdict to an image tag (digest binding) --image Graph linkage of scan → image

The common flags matter as much as the verbs, because they control the gate, the format, and where results land:

Flag Applies to What it does Notes
--id / --secret auth Service-account credential Source from OIDC-leased env vars
--policy "<name>" all scans Enforce a named Wiz CI/CD policy; non-zero exit on violation The gate; repeatable for multiple policies
--path iac, dir Directory or file to scan Point at the IaC dir or repo root
--image docker scan Image ref (tag or digest) to scan Scan the digest you will push
--dockerfile docker scan Dockerfile to scan statically Complements the image scan
--output <fmt,file,bool> all scans Emit results in a format (sarif, json, human) to a file The third arg controls whether the file is created on failure too
--name "<id>" all scans Tag the scan with a build identity Traceability in the console
--tag / --project all scans Attach project/labels for graph scoping Routes findings to the right Wiz project

A note on exit-code semantics, because the whole gate hinges on it: wizcli returns 0 when the scan passes the named policy, and non-zero when the policy is violated (or the scan errors). A common footgun is treating any non-zero as “the CLI broke” — distinguish a policy failure (the gate doing its job) from a tooling error (auth failed, network down). The table below is the exit-code contract you design steps around:

Exit code Meaning Your step should…
0 Scan ran; policy satisfied Pass; continue
non-zero (policy) Scan ran; policy violated at gate severity Fail the job (this is the block)
non-zero (auth) wizcli auth failed (bad secret, clock skew) Fail fast with a clear message; do not treat as clean
non-zero (network/CLI) Could not reach Wiz / CLI internal error Fail closed (default) or retry; never silently pass

Scanning infrastructure-as-code

IaC scanning is where the gate earns most of its keep, because a single Terraform module error propagates to every environment it is applied in. wizcli iac scan --path <dir> walks the directory, identifies the IaC type by content and extension, evaluates every resource against Wiz’s misconfiguration rules (mapped to CIS, the cloud provider benchmarks, and Wiz’s own controls), and returns findings joined to the graph.

Formats it understands

Wiz IaC scanning is multi-format — it is not Terraform-only, which matters in a mixed estate. Point --path at a directory and it discovers what is there:

IaC format Detected by Typical findings surfaced Notes
Terraform (HCL) *.tf, *.tf.json Public S3/Storage, Action:"*" IAM, unencrypted EBS/disks, open SGs/NSGs Scans files; -backend=false needs no remote state
Terraform plan (JSON) plan.json Same, evaluated against resolved values Catches computed values HCL alone hides
CloudFormation *.yaml/*.json templates Public resources, wildcard policies, unencrypted stores JSON and YAML templates both
Kubernetes *.yaml manifests privileged: true, hostNetwork, no resource limits, :latest images, over-broad RBAC Deployments, DaemonSets, RBAC, PSA gaps
Helm Chart.yaml + templates Rendered-manifest misconfigs Templating resolved before evaluation
ARM / Bicep *.json ARM, *.bicep Public Azure resources, missing encryption, weak TLS Azure-native IaC
Dockerfile (as IaC) Dockerfile :latest base, ADD from URL, root user, secrets in ENV Overlaps the Dockerfile scan; either surfaces it

The command and what a run looks like

The core invocation scans a directory and enforces a policy:

wizcli iac scan \
  --path ./infra \
  --name "kloudvin-iac-${GITHUB_SHA}" \
  --policy "Block-Critical-High-IaC" \
  --output sarif,wiz-iac.sarif,true

The --output sarif,wiz-iac.sarif,true triple means format = SARIF, file = wiz-iac.sarif, create-on-failure = true — the last flag is what guarantees the SARIF is written even when the policy fails, so the findings still upload to the PR. Human-readable output for local runs uses --output human (or no --output, which prints a table). For a resolved evaluation that catches computed values, feed a plan instead of raw HCL:

# Produce a plan JSON so the scan sees resolved values, then scan it
terraform -chdir=infra init -backend=false
terraform -chdir=infra plan -out=tf.plan
terraform -chdir=infra show -json tf.plan > infra/plan.json
wizcli iac scan --path ./infra --policy "Block-Critical-High-IaC"

Scanning the plan rather than the files matters when a value is computed — a bucket name assembled from variables, a policy built with jsonencode, a CIDR passed in as a variable. Raw-HCL scanning sees the template; plan scanning sees what will actually be created. For the highest fidelity, scan the plan; for the fastest feedback, scan the files. The trade-off table:

Scan target Fidelity Speed Needs Use when
Raw HCL (--path at .tf) Good for static values Fastest (no init/plan) Nothing Quick PR feedback, most misconfigs
Terraform plan JSON Highest (resolved values) Slower (init + plan) Provider creds or -backend=false Computed values, module-heavy code
Kubernetes manifests High for K8s Fast Nothing Cluster workload changes
Helm (rendered) High post-render Medium Chart values Chart changes

Reading and gating IaC findings

Each finding carries a severity, the rule it violated, the file and line, and — via the graph — the resource it would become and that resource’s exposure. The gate policy should key on severity and graph context, which is why the recommended IaC policy fails on Critical and High but not Medium/Low (those become PR comments, not blocks). The severity-to-action mapping we implement:

Severity Example IaC finding Gate action Rationale
Critical Public S3 bucket holding data; Action:"*" admin role Fail Direct, high-impact attack path
High Open security group 0.0.0.0/0:22; unencrypted DB Fail Serious exposure; cheap to fix pre-merge
Medium Missing bucket versioning; no access logging Warn (PR comment) Real but not a merge-blocker
Low Missing resource tags; suboptimal defaults Warn / info Hygiene; do not block on it
Informational Style/best-practice hints Info only Never gate

Scanning container images and Dockerfiles

Container scanning is the second pillar, and it runs in two complementary passes: the Dockerfile (static, pre-build) and the image (resolved, post-build).

The Dockerfile pass

wizcli docker scan --dockerfile ./Dockerfile reads the instructions and flags anti-patterns before any build cost is paid. What it catches:

Dockerfile finding Instruction Why it matters Fix
Unpinned base image FROM node:latest :latest is non-reproducible and may carry new CVEs Pin a digest or specific tag
Remote ADD ADD https://… /app Fetches unverified content at build Use curl with checksum, or vendor it
Hardcoded secret ENV API_KEY=sk-… Secret baked into every layer forever Use build secrets / runtime injection
Runs as root (no USER) Container runs privileged by default Add a non-root USER
Broad COPY . . COPY . /app Copies .git, secrets, junk into the image .dockerignore; copy narrowly
apt without cleanup no rm -rf /var/lib/apt/lists/* Bloated image, larger attack surface Clean in the same layer

The image pass and the SBOM

After docker build, wizcli docker scan --image <ref> inspects the resolved layers: the OS packages, the language dependencies, known CVEs with their fix status, embedded secrets, and (optionally) malware. Crucially, it can extract an SBOM in the same pass, which becomes the artifact you archive for VEX triage and admission verification later:

wizcli docker scan \
  --image kloudvin/api:${GITHUB_SHA} \
  --name "kloudvin-image-${GITHUB_SHA}" \
  --policy "Block-Critical-Fixable-Vulns" \
  --sbom-format cyclonedx-json \
  --sbom-output sbom.cdx.json \
  --output sarif,wiz-image.sarif,true

The most important vulnerability-policy decision is fail on Critical with a fix available, warn on unfixable. Blocking a build on a Critical CVE that has no patch teaches developers to route around the gate — there is no action they can take, so the gate becomes an obstacle rather than a control. Scoping the block to fixable Criticals keeps the gate actionable. The vulnerability-gating matrix we implement:

Vulnerability class Fix available? Gate action Rationale
Critical CVE, exploitable, on load path Yes Fail Patchable, high-impact — block and fix
Critical CVE No fix yet Warn + track No action possible; blocking is noise
High CVE Yes Fail (or warn, per risk appetite) Serious; usually block if fixable
High CVE No Warn Track via VEX; do not block
Medium/Low Any Warn / info Burn down over time, not per-PR
Malware signature Fail always Never ship a flagged binary

SBOM formats: Wiz can emit CycloneDX or SPDX, in JSON. Which you pick depends on what consumes it downstream:

SBOM format Strength Consumed by Choose when
CycloneDX JSON Rich vuln/VEX linkage, component graph Wiz, Dependency-Track, most scanners Default for security workflows
SPDX JSON License-focused, ISO-standard, broad tooling License compliance, SBOM registries License/compliance is the driver

Binding the scan to the digest

The scan is only trustworthy if it evaluated the exact artifact you ship. Scan by digest, not a mutable tag, and bind the verdict to that digest so the graph and any admission check reference the same immutable image. Build, capture the digest, scan the digest:

docker build -t kloudvin/api:${GITHUB_SHA} .
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' kloudvin/api:${GITHUB_SHA} 2>/dev/null || echo "kloudvin/api:${GITHUB_SHA}")
wizcli docker scan --image "$DIGEST" --policy "Block-Critical-Fixable-Vulns"

Secret and SBOM scanning

Secrets are the highest-severity, lowest-tolerance finding class: a single verified cloud key in the working tree or git history is an immediate Critical, because it is exploitable the moment it lands in a public or over-shared repo. Wiz scans for secrets in two places — the working tree/directory (wizcli dir scan) and inside image layers (part of the image scan) — and it distinguishes verified secrets (patterns confirmed as live, high-confidence) from unverified candidates (patterns that look like secrets but may be examples).

The secrets-policy posture is the strictest of all: fail on any verified secret, warn on unverified candidates (to avoid blocking on EXAMPLE_KEY=xxxxx placeholders). The secret-gating matrix:

Secret finding Confidence Gate action Notes
Live AWS/Azure/GCP key Verified Fail Rotate immediately; do not just delete the commit
Private key / cert Verified Fail Purge history, rotate the key material
DB connection string with creds Verified Fail Move to a secrets manager
Token-shaped string Unverified Warn May be a placeholder/example
High-entropy string Heuristic Info Review; often false positive

A critical operational point: finding a secret in a PR means the secret is already compromised and must be rotated, not merely removed from the diff. Deleting the commit does not un-leak a key that was pushed to GitHub’s servers (and possibly forks, caches, and the reflog). This dovetails with the dedicated remediation workflow in Eliminating Secret Sprawl: Pipeline Scanning, Push Protection, and Leaked-Credential Remediation — the gate catches it; that playbook is how you respond.

The dir scan also produces an SBOM for non-container code (a Node/Python/Go project without an image), so the SBOM story covers both containerized and source-only repos:

# Secrets + dependency SBOM for a source tree (no image)
wizcli dir scan \
  --path . \
  --name "kloudvin-src-${GITHUB_SHA}" \
  --policy "Block-Verified-Secrets" \
  --sbom-format cyclonedx-json \
  --sbom-output src-sbom.cdx.json

The code-to-cloud graph and Wiz Sensor context

This is the section that separates Wiz Code from a generic scanner, and it is why the gate is prioritized rather than noisy. A raw scanner produces a flat list: 400 findings, 30 Critical. A team drowning in 30 “Criticals” — most of which are unreachable, in sandboxes, or theoretical — learns to ignore all of them. Wiz’s answer is the code-to-cloud graph: every finding is a node joined to what it becomes and what it touches.

What the graph joins

The graph connects, for a single finding, the code artifact, the cloud resource it provisions, that resource’s network exposure, the identity it can assume, the data it can reach, and the runtime signal from the sensor. Concretely:

Graph edge From To What it tells the gate
code → resource Terraform aws_s3_bucket The provisioned bucket Which running resource this PR creates/changes
resource → exposure The bucket Internet / VPC / private Is it publicly reachable?
resource → identity An EC2/pod The role it assumes What blast radius a compromise has
resource → data The bucket/DB Sensitivity (PII/secrets) What is at stake
image → workload The scanned image Running pods (via sensor) Is this image actually deployed?
finding → attack path The chain above Exposure→identity→data A ranked, real attack path vs a flat CVE

Why the Wiz Sensor changes the math

The Wiz Sensor is an eBPF agent on your hosts and clusters. It is not required for scanning — you can gate PRs with Wiz Code and no sensor — but when present, it feeds runtime signal into the graph that transforms prioritization. Two examples make the difference concrete:

This is runtime-informed prioritization, and it is the argument for scoping your gate policy to graph-rated severity rather than raw CVSS. The comparison:

Prioritization basis What it uses Failure mode Wiz Code position
Raw CVSS severity The CVE score alone Alert fatigue; 30 “Criticals,” all noise Insufficient alone
Fix-available filter CVSS + patch status Better, still context-free Necessary, not sufficient
Reachability (code) Is the code path called? Static reachability is approximate Good signal
Runtime (sensor) Is it loaded + exposed + privileged? Requires sensor coverage The differentiator
Attack-path (graph) All of the above joined Requires the graph The Wiz model — gate on this

Practically, this means: deploy the sensor where you can (it makes findings dramatically more accurate), but design the gate so it works without the sensor (fixable-Critical + graph-exposure is a solid policy even sensor-free), and let the sensor tighten prioritization where it is present. Where Wiz Code stops and the sensor/EDR take over is the boundary of this article — for runtime threat detection on the workloads once they ship, see Deploy CrowdStrike Falcon Sensor to Linux Fleets and Kubernetes via Helm DaemonSet.

Policy gates and CI/CD policies

The gate is a Wiz CI/CD policy invoked by --policy. Getting the policy set right — one per scan class, scoped by severity and graph context — is the difference between a gate the team respects and one they route around. Create these in Wiz console → Policies → Create Policy → CI/CD.

The policy set

The recommended baseline is four policies, one per class, each named so wizcli can reference it:

Policy name Class Fails on Warns on
Block-Critical-High-IaC IaC misconfig Critical + High misconfigurations Medium/Low
Block-Critical-Fixable-Vulns Image/dep vulns Critical CVEs with a fix + malware Unfixable Critical; High
Block-Verified-Secrets Secrets Any verified secret Unverified candidates
Warn-License-SBOM SBOM/license (nothing — report only) Copyleft/denied licenses

Where the enforcement lives

The exit code is the mechanism. Each wizcli … --policy <name> step exits non-zero on violation, which fails the job step, which fails the job, which fails the required check. Three properties make it a real gate rather than a report:

  1. Non-zero exit on policy violation → the step and job fail. This is the block.
  2. SARIF upload with if: always() → findings post inline on the PR even when an earlier gate failed (otherwise the failing scan hides its own annotations).
  3. --name / --project tagging → results land in the Wiz console under a traceable build identity, so security sees the same finding the developer sees.

A subtle point on ordering and set -e: if you run multiple scans in one shell step, an early failure with set -e (the default in run: blocks) aborts before later scans run. For independent gates you usually want each scan as its own step so all of them run and all of them annotate, with the job failing if any failed. The exemplar workflow below does exactly that.

Fail-open vs fail-closed

A design decision with security consequences: if wizcli cannot reach Wiz (network blip, auth expiry), should the step pass (fail-open) or fail (fail-closed)? For a security gate the default is fail-closed — a scan that could not run is not a scan that passed. Fail-open is only defensible as a temporary, logged, time-boxed measure during a known Wiz outage, and even then you are accepting unscanned merges. The trade-off:

Mode On scan error Pro Con Use when
Fail-closed (default) Block the merge No unscanned code merges A Wiz outage blocks all merges Almost always
Fail-open Allow the merge Merges continue during outage Unscanned code ships Only a logged, time-boxed break-glass

PR annotations, comments, and developer experience

A gate that only turns a check red teaches developers nothing about what to fix. The experience that changes behavior is the finding rendered on the exact line of the diff, and Wiz Code delivers that through SARIF upload plus (optionally) PR comments.

SARIF → GitHub annotations. Each scan emits a SARIF file; github/codeql-action/upload-sarif ingests it and GitHub renders each finding as an annotation on the changed line, and in the Security → Code scanning tab. The developer sees “Public S3 bucket — line 42” without leaving the PR. The category: on the upload separates IaC findings from image findings so they do not collide in the Security tab.

Wiz native PR comments. Beyond SARIF, the Wiz GitHub app (if installed) can post a summary comment on the PR — a roll-up of new findings, severities, and links to the graph context in the console. This is the “here is the attack path, not just the CVE” view that SARIF alone cannot convey.

The developer-experience surfaces, compared:

Surface Source Granularity Shows graph context? Best for
Inline annotation SARIF upload Per line No (just the finding) “Fix this exact line”
Security → Code scanning tab SARIF upload Per finding, historical No Triage over time
PR summary comment Wiz GitHub app Roll-up per PR Yes (links to attack path) “Is this a real risk?”
Wiz console The scan --name Full graph Yes Security-team deep dive
Check status (red/green) Job exit code Pass/fail No The merge gate itself

Baselining, ignores, and exception hygiene

The single biggest reason security gates get disabled is first-day alert fatigue on a brownfield repo. Turning on a Critical-blocking gate against a codebase with 200 pre-existing findings blocks every PR immediately, for debt no individual PR created. The fix is disciplined suppression, and Wiz gives you three tools with different scopes.

The three suppression mechanisms

Mechanism Scope Where defined Lifetime Use for
Baseline The whole repo’s current findings Wiz project / baseline file Until re-baselined Brownfield adoption: gate on new only
.wizignore Specific finding IDs/paths Repo root file Per-entry expires Documented, time-boxed exceptions in code
Policy ignore rule A finding class org-wide Wiz console policy Until removed Systematic false positives / accepted patterns

Baselining draws a line: mark today’s findings as accepted, and the gate fails only on findings a PR introduces. This is what makes adoption humane — you block regressions immediately, then burn down the baseline on a schedule, without holding the whole team hostage to pre-existing debt. Re-baseline deliberately (not automatically), so you do not silently accept new debt.

.wizignore lives in the repo, is reviewed like code, and every entry carries a reason and an expiry. It is for the specific, justified exception — “this demo bucket is intentionally public, non-prod, expires 2026-09-01.” The format:

cat > .wizignore <<'EOF'
# finding-id / rule           path (optional)          reason                          expires
wiz-iac-aws-s3-public         infra/sandbox/*          demo bucket, non-prod only       2026-09-01
wiz-vuln-CVE-2025-XXXXX       -                        no fix upstream; tracked in JIRA-1234  2026-08-15
EOF

The discipline that keeps .wizignore from becoming a junk drawer — the failure mode that hollows out the gate:

Rule Why Enforce by
Every entry has a reason An unexplained ignore is invisible risk PR review; reject reasonless entries
Every entry has an expiry Permanent ignores never get revisited CI lint that flags expired/expiring entries
Entries are reviewed by security, not devs alone Devs should not self-approve weakening the gate CODEOWNERS on .wizignore → security team
Prefer narrow paths over broad globs A broad ignore suppresses future real findings Review scope; scope to the specific dir
Fix beats ignore An ignore is deferred risk, not resolved risk Track ignores as debt; burn them down

The exception lifecycle

An ignore is a promise to revisit, so it needs a lifecycle: created (with reason + expiry) → reviewed (by security) → tracked (as debt) → expired (CI flags it) → resolved-or-renewed (fix it or consciously re-accept). The lifecycle table:

Stage Trigger Owner Artifact
Create Justified false-positive / accepted risk Developer .wizignore entry (reason + expiry)
Review PR touching .wizignore Security (CODEOWNERS) Approved diff
Track Entry merged Security Debt ticket (JIRA/ServiceNow)
Expire Expiry date reached CI lint Failing lint / flagged PR
Resolve Fix lands, or risk re-accepted Developer + Security Removed entry or renewed expiry

Admission-style enforcement: carrying the gate past CI

CI is the first gate, not the last. Two gaps make CI-alone insufficient: infrastructure drifts outside the pipeline (a console change, a break-glass edit, a resource created by a different tool never sees the PR gate), and an image that passed on Tuesday grows new CVEs by Thursday as vulnerabilities are disclosed. Admission-style enforcement re-checks the artifact at deploy time, so the same policy that gated the PR also gates what actually reaches the cluster.

The layered enforcement model — each layer covers a gap the previous one leaves:

Layer Where it runs Catches Gap it closes
PR gate (this guide) GitHub Actions on pull_request Bad IaC/image/secret before merge The moment of authorship
Registry scan On push to the registry Vulns discovered after build New CVEs on a stored image
Admission control K8s admission at deploy Unscanned/failing images reaching the cluster Images that bypass CI
Agentless CSPM Continuous cloud scan Drift introduced outside IaC Console/break-glass changes
Runtime sensor eBPF on the workload Exploitation of what shipped Post-deploy threats

Admission control (via an admission controller keyed on the Wiz scan verdict, or a policy engine that consults the graph) refuses to admit a pod whose image has not passed policy — so even an image pushed by a path that skipped CI is stopped at the cluster door. This is the same digest-bound verdict the PR scan produced, which is why scanning by digest (not a mutable tag) in CI matters: the admission check references the exact artifact CI evaluated. The deeper mechanics of admission verification against SBOM/VEX are covered in Consuming the Software Supply Chain: SBOM Ingestion, VEX Triage, and Admission Verification.

The CI wiring: OIDC, service accounts, and secret hygiene

The workflow needs the Wiz client ID and secret to authenticate, and how it gets them is a security decision in its own right. The worst option (hardcoded in the YAML) is off the table; the acceptable-but-weaker option is an encrypted Actions secret; the strong option is OIDC-leased at run time so nothing long-lived lives in GitHub. The comparison:

Credential source Long-lived secret in GitHub? Blast radius if repo leaks Effort Recommendation
Hardcoded in workflow Yes (in git!) Total — key is in history Never
Encrypted Actions secret Yes (in GitHub secret store) The static secret leaks Low Acceptable fallback
OIDC → Vault (lease at run) No Nothing static to leak; TTL-bound Medium Preferred
OIDC → cloud secrets manager No Nothing static; scoped by claims Medium Also strong

The OIDC pattern: GitHub mints a short-lived JWT for the job (requires id-token: write), a broker (HashiCorp Vault, or a cloud secrets manager with OIDC trust) validates the JWT’s claims (repo, ref, environment) and returns the Wiz secret with a short TTL, and wizcli auth uses it. The Wiz secret never sits in GitHub. Store the credential in Vault and configure the trust:

# Store the Wiz CI/CD credential in Vault (KV v2)
vault kv put secret/ci/wiz \
  client_id="$WIZ_CLIENT_ID" \
  client_secret="$WIZ_CLIENT_SECRET"

# Trust GitHub's OIDC issuer
vault auth enable -path=github-actions jwt
vault write auth/github-actions/config \
  oidc_discovery_url="https://token.actions.githubusercontent.com" \
  bound_issuer="https://token.actions.githubusercontent.com"

# A role bound to this repo + PR refs, with a short token TTL
vault write auth/github-actions/role/wiz-scan \
  role_type="jwt" \
  user_claim="actor" \
  bound_audiences="https://github.com/kloudvin" \
  bound_claims_type="glob" \
  bound_claims='{"repository":"kloudvin/*","ref":"refs/pull/*"}' \
  token_policies="wiz-ci-read" \
  token_ttl=15m

The bound_claims restriction is what stops a fork or an unrelated repo from minting the credential — it ties issuance to your repo and PR refs. This is the same secretless-CI/CD pattern detailed in Secretless CI/CD: Workload Identity Federation for GitHub Actions and AKS and the broader CI/CD Secrets and Credential Management: Secure Your Pipelines; for syncing the secret into other runtimes see Set Up External Secrets Operator to Sync Vault and AWS Secrets into Kubernetes.

The service account itself must be least-privileged. Scope it to CI/CD scanning only — it does not need read over your whole cloud inventory, only the ability to run scans and post their results. The permission posture:

Service-account scope Grants Risk if leaked Use for
CI/CD scanning (recommended) Run scans, submit results, evaluate policy Attacker can run scans (low value) The pipeline
Project-scoped read Read one project’s findings One project’s findings exposed Reporting integrations
Tenant admin Everything Total tenant compromise Never for CI

The workflow YAML, end to end

Here is the complete workflow — the heart of the integration. It leases the credential via OIDC, installs a pinned wizcli, authenticates, and runs four gated scans each as its own step (so all annotate even if one fails), uploads SARIF, and archives the SBOM. Create it as .github/workflows/wiz-code-gates.yml:

name: Wiz Code Security Gates

on:
  pull_request:
    branches: [main]

permissions:
  contents: read
  id-token: write        # required: GitHub OIDC -> Vault
  security-events: write # required: upload SARIF to the PR / Security tab
  pull-requests: write   # required: PR comments

env:
  WIZCLI_VERSION: "latest"   # pin a real version in production, e.g. "1.x.y"

jobs:
  wiz-scan:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0   # full history so secret scanning sees prior commits

      # Lease the Wiz CI/CD credential from Vault via GitHub OIDC (no static secret)
      - name: Import Wiz secret from Vault
        id: vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.kloudvin.internal:8200
          method: jwt
          path: github-actions
          role: wiz-scan
          secrets: |
            secret/data/ci/wiz client_id     | WIZ_CLIENT_ID ;
            secret/data/ci/wiz client_secret | WIZ_CLIENT_SECRET

      - name: Install wizcli
        run: |
          curl -sSLo wizcli "https://wizcli.app.wiz.io/${WIZCLI_VERSION}/wizcli"
          chmod +x wizcli
          sudo mv wizcli /usr/local/bin/wizcli
          wizcli version

      - name: Authenticate wizcli to the Wiz tenant
        run: wizcli auth --id "$WIZ_CLIENT_ID" --secret "$WIZ_CLIENT_SECRET"

      # GATE 1 — IaC misconfiguration (Terraform / CFN / K8s / ARM)
      - name: Wiz IaC scan
        run: |
          wizcli iac scan \
            --path ./infra \
            --name "kloudvin-iac-${{ github.sha }}" \
            --policy "Block-Critical-High-IaC" \
            --output sarif,wiz-iac.sarif,true

      - name: Upload IaC SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: wiz-iac.sarif
          category: wiz-iac

      # GATE 2 — Secrets in the working tree + full git history
      - name: Wiz secrets (dir) scan
        run: |
          wizcli dir scan \
            --path . \
            --name "kloudvin-secrets-${{ github.sha }}" \
            --policy "Block-Verified-Secrets"

      # GATE 3 — Dockerfile (static, pre-build)
      - name: Wiz Dockerfile scan
        run: |
          wizcli docker scan \
            --dockerfile ./Dockerfile \
            --name "kloudvin-dockerfile-${{ github.sha }}" \
            --policy "Block-Critical-High-IaC"

      # Build the exact artifact we intend to ship
      - name: Build image
        run: docker build -t kloudvin/api:${{ github.sha }} .

      # GATE 4 — Image layers + SBOM (post-build, resolved)
      - name: Wiz image scan (+ SBOM)
        run: |
          wizcli docker scan \
            --image kloudvin/api:${{ github.sha }} \
            --name "kloudvin-image-${{ github.sha }}" \
            --policy "Block-Critical-Fixable-Vulns" \
            --sbom-format cyclonedx-json \
            --sbom-output sbom.cdx.json \
            --output sarif,wiz-image.sarif,true

      - name: Upload image SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: wiz-image.sarif
          category: wiz-image

      - name: Archive SBOM
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: sbom-${{ github.sha }}
          path: sbom.cdx.json

Why each design choice is deliberate — the annotated rationale:

Choice Why
pull_request to main only Gate what will merge; avoid scanning every branch push (cost)
Each scan a separate step With set -e, one failing scan won’t skip the others — all annotate
if: always() on uploads Findings still post when an earlier gate failed (else they hide)
Pin WIZCLI_VERSION in prod A CLI release can change exit semantics; pin for reproducibility
fetch-depth: 0 Secret scanning needs history, not just the tip commit
Scan Dockerfile and image Static smells + resolved CVEs are different finding sets
SBOM archived on always() You want the bill of materials even on a failing run
id-token: write present Without it the OIDC handshake to Vault 403s and the job dies pre-scan

Making the check required (branch protection)

A red check that does not block merge is theater. Promote the job to a required status check so GitHub refuses the merge while it is failing. Via the GitHub CLI:

gh api -X PUT repos/kloudvin/api/branches/main/protection \
  -H "Accept: application/vnd.github+json" \
  -f 'required_status_checks[strict]=true' \
  -f 'required_status_checks[contexts][]=wiz-scan' \
  -F 'enforce_admins=true' \
  -f 'required_pull_request_reviews[required_approving_review_count]=1' \
  -F 'restrictions=null'

enforce_admins=true is deliberate — the gate should bind everyone, including the people who can edit it, or it will be bypassed under deadline pressure. strict=true requires the branch to be up to date before merge, so the scan that ran is the scan of what will actually land. If your org standardizes via rulesets, create an org ruleset targeting main with a “Require status checks to pass” rule naming wiz-scan; the effect is identical and applies across every repo at once. The protection knobs that matter for a security gate:

Setting Value Effect Why
required_status_checks.contexts wiz-scan Merge blocked while the check is red The gate
strict true Branch must be current before merge Scan matches the merge result
enforce_admins true Admins cannot bypass Gate binds everyone
required_pull_request_reviews ≥1 Human review alongside the scan Defense in depth
Org ruleset (alternative) targets main Same rule across all repos Scale beyond one repo

Architecture at a glance

The flow is a single PR-triggered pipeline with four gates, each backed by a Wiz CI/CD policy, all feeding one console and one graph. Read the diagram left to right. A developer opens a pull request against main. GitHub Actions checks out the code and, using GitHub OIDC, authenticates to HashiCorp Vault to lease the Wiz CI/CD client ID and secret — short-lived, never stored in the repo. The job installs a pinned wizcli, runs wizcli auth to exchange the service-account credential for a session token, and then runs four scans: an IaC scan over the Terraform/CloudFormation/Kubernetes, a secrets/dir scan over the working tree and git history, a Dockerfile scan statically, and — after docker build — an image scan with SBOM extraction over the resolved layers. Each scan enforces its named CI/CD policy; a policy violation makes wizcli exit non-zero, which fails the step, fails the wiz-scan job, turns the required check red, and — via branch protection — blocks the merge.

Every scan uploads its results to the Wiz tenant under a traceable --name, so findings appear in the same console the security team already uses, joined into the code-to-cloud graph where each finding is linked to the cloud resource it becomes and (via the Wiz Sensor, if deployed) the runtime signal that ranks it as a real attack path or noise. In parallel, each scan emits SARIF that GitHub renders as inline annotations on the PR diff and in the Security tab, and the image scan’s CycloneDX SBOM is archived as a workflow artifact for later VEX/admission use. On a clean run the image is pushed and the baton passes to the runtime side — admission control re-checks the digest-bound verdict at the cluster door, agentless CSPM watches for drift introduced outside the pipeline, and the Wiz Sensor plus CrowdStrike Falcon provide runtime threat detection on the live workload. Optionally a failing gate raises a ServiceNow ticket so security has an auditable record, not just a red check that scrolls away.

Wiz Code integrated into a GitHub Actions pull-request pipeline: a developer PR triggers a job that leases the Wiz service-account credential from HashiCorp Vault via GitHub OIDC, installs and authenticates wiz-cli, then runs four policy-gated scans in sequence — IaC misconfiguration over Terraform/CloudFormation/Kubernetes, secrets over the working tree and git history, the Dockerfile statically, and the built image with SBOM extraction — each enforcing a named Wiz CI/CD policy whose non-zero exit fails the required status check and, via branch protection, blocks the merge; results upload to the Wiz tenant and its code-to-cloud graph (enriched by Wiz Sensor runtime context) while SARIF renders inline PR annotations and the CycloneDX SBOM is archived, with the runtime baton passing to admission control, agentless CSPM, and CrowdStrike Falcon once the artifact ships

Real-world scenario

Northwind Freight runs a logistics platform across two clouds: Terraform provisions AWS (the customer-facing API on EKS, an RDS PostgreSQL, a fleet of S3 buckets) and Azure (an internal analytics stack), and every service image builds FROM a shared northwind/base:debian-slim. The platform team is six engineers merging roughly 40 infrastructure and application PRs a week. Security is two people. Before this integration, their control was Wiz’s agentless AWS connector scanning the running estate nightly — good coverage, but everything it found was already deployed, and the two-person security team was drowning in tickets against code that shipped days earlier.

The trigger was an incident. A refactor of the S3 module — extracting a reusable bucket submodule — flipped a default: the new module omitted the aws_s3_bucket_public_access_block resource, and three buckets (one holding customer shipment manifests) went public on merge. Wiz’s nightly scan caught it eleven hours later; by then the buckets had been publicly listable for most of a business day. No data was proven exfiltrated, but the incident review was brutal: the misconfiguration was in the PR, perfectly diagnosable, and nothing looked at the PR. The CISO’s directive: gate it.

They wired Wiz Code into GitHub Actions exactly as this guide describes, but the adoption nearly failed on day one for the classic reason — the first PR after enabling the gate blocked immediately on 214 pre-existing findings, none of which that PR created. The team’s instinct was to disable the gate. Instead they baselined: marked the 214 as the accepted starting set, so the gate failed only on new findings, and scheduled a backlog burn-down of two findings per sprint. Overnight the gate went from “blocks everything” to “blocks only regressions” — and it caught its first real one within three days: a PR that reintroduced the exact public-bucket pattern from the incident. The wiz-scan check went red, the finding annotated line 31 of the module, the developer fixed it in one commit, and the buckets never went public. The security team saw the finding in the same console, joined in the graph to “S3 bucket, would-be-internet-exposed, holds shipment data” — a Critical attack path, not a flat warning.

The vulnerability side taught the second lesson. The shared northwind/base:debian-slim picked up a Critical libssl CVE the week it was disclosed. Because every service builds FROM it, the next image scan on every service PR failed — 40 PRs a week, all red, all on the same unpatchable-that-day CVE. Blocking all of them would have ground the team to a halt. The Block-Critical-Fixable-Vulns policy saved them: the CVE had no fix upstream yet, so it was scoped to warn, not fail — the team saw it, tracked it in a ticket, and merged; when Debian shipped the patch two days later, they rebuilt the base, and the warning cleared everywhere at once. Had the policy blocked on all Criticals rather than fixable Criticals, the team would have disabled the gate under pressure — the exact failure this article warns against.

Six weeks in, the numbers: zero misconfigurations reached production that the gate could have caught (down from a running average of three to four per month), mean-time-to-fix for a caught misconfiguration dropped from eleven hours (nightly-scan-to-ticket-to-fix) to under two minutes (annotation-to-commit), and the two-person security team stopped writing tickets against shipped code and started reviewing .wizignore exceptions instead — a strategic role, not a reactive one. The lesson on the wall: “The gate is only adoptable if it blocks regressions, not debt — baseline first, scope to fixable, and let the graph tell you what’s actually a Critical.”

The incident-to-steady-state timeline, because the order of moves is the lesson:

Phase State Action Effect
Before Nightly cloud scan only (findings after deploy) 11-hour detection; ticket backlog
Incident 3 buckets public on merge Caught by nightly scan Public for most of a business day
Day 1 Gate enabled, 214 findings First PR blocks on debt Team nearly disables the gate
Day 1 (fix) Baseline set Gate on new findings only Blocks regressions, not debt
Day 3 Public-bucket pattern reintroduced Gate red, annotation on line 31 Fixed in one commit; never public
Week 2 Base image gets unfixable Critical fixable-only policy warns, not fails 40 PRs merge; CVE tracked
Week 2+2d Debian patches Rebuild base Warning clears everywhere at once
Week 6 Steady state Security reviews exceptions 0 preventable misconfigs shipped; MTTF < 2 min

Advantages and disadvantages

Wiz Code’s model — graph-joined, policy-gated, pre-merge scanning — both delivers real value and imposes real constraints. Weigh it honestly:

Advantages Disadvantages
Findings joined to the code-to-cloud graph — you gate on real attack paths, not flat CVSS, which slashes false-positive fatigue The graph and prioritization value depend on a Wiz tenant + (ideally) Sensor; sensor-free you lose the runtime-reachability signal
One console and one graph for code + cloud + runtime — security sees the PR finding and the running-resource context together Vendor consolidation on Wiz; less tool-choice flexibility than stitching best-of-breed OSS
Policy lives in Wiz, not YAML — security owns the threshold centrally; devs can’t weaken it by editing the workflow Central policy is a coordination point; a too-strict policy blocks everyone until security relaxes it
Multi-format IaC (TF/CFN/K8s/ARM/Helm) + image + secrets + SBOM in one CLI — one gate covers the whole surface Four scan types add CI minutes and a moving-part surface to maintain
SARIF + PR comments put findings on the exact diff line — behavior-changing developer experience SARIF alone lacks graph context; the richer view needs the Wiz GitHub app installed
Baselining makes brownfield adoption humane — block regressions, not pre-existing debt Baselining can silently accept new debt if you re-baseline carelessly
Licensed within the Wiz subscription — marginal cost is mostly CI minutes, not per-scan fees The Wiz subscription itself is an enterprise-tier cost; not a fit for a hobby project
Digest-bound verdicts enable admission-style enforcement — the gate carries past CI to the cluster Full admission enforcement is additional setup (controller + policy engine) beyond the PR gate

The model is right for a team with meaningful multi-cloud IaC velocity and container output, an existing (or planned) Wiz investment, and a security team that wants to shift from reactive ticketing to policy ownership. It is over-engineered for a single-repo hobby project (use OSS scanners) and it presumes the Wiz platform is already the estate’s posture tool — bolting Wiz Code onto a shop with no other Wiz footprint loses most of the graph value that justifies it. The disadvantages are all manageable — baseline carefully, scope policies to fixable/graph-rated, pin the CLI, deploy the sensor where you can — but only if you know they exist, which is the point of this article.

Hands-on lab

This lab proves the gate end to end on a real repository: you will reproduce a blocked PR from a public S3 bucket, watch wizcli exit non-zero, see the finding annotate the diff, fix it, and confirm the check flips green — then tear everything down. It is designed to run against your own Wiz tenant with a CI/CD service account. Where a Wiz tenant is not available, Steps 1–3 and 9 (the local validation and teardown) still demonstrate the mechanics; the PR-gate steps need the tenant.

Step 0 — Prerequisites check. Confirm you have a Wiz service account (client ID + secret) and a test GitHub repo you own with Actions enabled.

# You should have these two values from Wiz Settings -> Service Accounts
echo "Client ID present: ${WIZ_CLIENT_ID:+yes}"
echo "Client secret present: ${WIZ_CLIENT_SECRET:+yes}"

Expected: both print yes. If not, create the service account first (Wiz console → Settings → Service Accounts → Add, type CI/CD).

Step 1 — Install wizcli locally.

curl -sSLo wizcli https://wizcli.app.wiz.io/latest/wizcli
chmod +x wizcli
sudo mv wizcli /usr/local/bin/wizcli
wizcli version

Expected: a version string prints (e.g. wizcli version 1.x.y). If curl fails, check egress to wizcli.app.wiz.io.

Step 2 — Authenticate.

wizcli auth --id "$WIZ_CLIENT_ID" --secret "$WIZ_CLIENT_SECRET"

Expected: Authentication successful (or equivalent) and a cached session. A non-zero exit here means bad credentials or clock skew — fix before continuing; do not proceed treating auth failure as “clean.”

Step 3 — Reproduce a blocked IaC finding locally. Create an obviously-bad Terraform file — a public S3 bucket — and scan it against your IaC policy:

mkdir -p /tmp/wizlab/infra
cat > /tmp/wizlab/infra/main.tf <<'EOF'
resource "aws_s3_bucket" "leak" {
  bucket = "kv-wizlab-leak-bucket"
}
resource "aws_s3_bucket_public_access_block" "leak" {
  bucket                  = aws_s3_bucket.leak.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}
EOF

wizcli iac scan --path /tmp/wizlab/infra --policy "Block-Critical-High-IaC"; echo "exit=$?"

Expected: a Critical/High finding for public S3 exposure and a non-zero exit=. That non-zero exit is the entire gate mechanism — it is what will fail the GitHub step. If it exits 0, your policy is not gating on this class; check the policy’s severity threshold in the Wiz console.

Step 4 — Add the workflow to your repo. In your test repo, create .github/workflows/wiz-code-gates.yml. For the lab, use the simpler encrypted-secret path (skip the Vault step) — set WIZ_CLIENT_ID and WIZ_CLIENT_SECRET under Settings → Secrets and variables → Actions, and use this trimmed workflow:

name: Wiz Code Security Gates
on:
  pull_request:
    branches: [main]
permissions:
  contents: read
  security-events: write
jobs:
  wiz-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install wizcli
        run: |
          curl -sSLo wizcli https://wizcli.app.wiz.io/latest/wizcli
          chmod +x wizcli && sudo mv wizcli /usr/local/bin/wizcli
      - name: Auth
        run: wizcli auth --id "${{ secrets.WIZ_CLIENT_ID }}" --secret "${{ secrets.WIZ_CLIENT_SECRET }}"
      - name: IaC scan
        run: |
          wizcli iac scan --path ./infra \
            --name "wizlab-${{ github.sha }}" \
            --policy "Block-Critical-High-IaC" \
            --output sarif,wiz-iac.sarif,true
      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: wiz-iac.sarif
          category: wiz-iac

Commit this to main.

Step 5 — Make the check required.

gh api -X PUT repos/<you>/<repo>/branches/main/protection \
  -f 'required_status_checks[strict]=true' \
  -f 'required_status_checks[contexts][]=wiz-scan' \
  -F 'enforce_admins=true' \
  -F 'required_pull_request_reviews=null' \
  -F 'restrictions=null'

Expected: JSON confirming protection is set with wiz-scan as a required context.

Step 6 — Open a PR that introduces the bad bucket. On a branch, add the same infra/main.tf from Step 3, push, and open a PR against main.

git checkout -b add-bad-bucket
mkdir -p infra && cp /tmp/wizlab/infra/main.tf infra/main.tf
git add infra/main.tf && git commit -m "add bucket (intentionally bad for lab)"
git push -u origin add-bad-bucket
gh pr create --fill --base main

Expected on the PR:

Check Expected state
wiz-scan status check Red / failed
Finding on the diff Inline annotation on the public_access_block lines
Merge button Blocked (“Required check failing”)
Security → Code scanning tab The public-bucket finding listed

Step 7 — Fix it in the same PR. Flip the booleans to true and push:

sed -i.bak 's/= false/= true/g' infra/main.tf && rm -f infra/main.tf.bak
git commit -am "fix: block public access on the bucket"
git push

Expected: the workflow re-runs, wizcli iac scan exits 0, the wiz-scan check flips green, and the merge unblocks.

Step 8 — Confirm the console and (if wired) the SBOM. In the Wiz console, find the scan under the name wizlab-<sha> and confirm the finding was recorded and then resolved. If you added the image-scan step, confirm the SBOM artifact is attached to the workflow run.

Step 9 — Teardown. Remove the gate and clean up, in reverse order of how you built it:

# Drop wiz-scan from required checks
gh api -X PUT repos/<you>/<repo>/branches/main/protection \
  -f 'required_status_checks[strict]=true' \
  -F 'required_status_checks[contexts][]=' \
  -F 'enforce_admins=true' -F 'required_pull_request_reviews=null' -F 'restrictions=null'

# Disable the workflow (keeps the file, stops runs)
gh workflow disable "Wiz Code Security Gates" -R <you>/<repo>

# Local cleanup
rm -rf /tmp/wizlab

Finally, in the Wiz console delete or disable the CI/CD service account so the now-unused credential cannot be replayed — deleting the workflow file alone is not enough; always revoke the credential. If you used the Vault path, also vault delete auth/github-actions/role/wiz-scan and vault kv metadata delete secret/ci/wiz.

Common mistakes & troubleshooting

Each of these is a real failure mode with a distinct fingerprint. Match the symptom, confirm the cause, apply the fix:

# Symptom Root cause Confirm Fix
1 Job dies before any scan with a 403 Missing id-token: write; OIDC handshake to Vault fails Workflow log shows OIDC/JWT 403 at the Vault step Add id-token: write to permissions:
2 Findings never appear on the PR Missing security-events: write; SARIF upload no-ops Upload step “succeeds” but Security tab empty Add security-events: write; check category
3 Check goes red but merge still works Not a required check, or enforce_admins=false Branch protection shows wiz-scan not required Make it required; enforce_admins=true
4 Only Dockerfile scanned; CVEs slip through Skipped the image scan; static-only No wizcli docker scan --image step present Add the post-build image scan
5 Every PR blocks on day one Gating on pre-existing debt, no baseline 200+ findings, none new to the PR Baseline the current set; gate on new only
6 Team routes around the gate Blocking on unfixable Critical CVEs Failing CVE has no upstream patch Scope vuln policy to fixable Criticals
7 Auth failure treated as “clean” Step swallows non-zero auth exit wizcli auth failed but job passed Fail fast on auth error; never continue
8 Scan passes but shouldn’t --policy name typo → wrong/no policy applied Console shows scan ran with no policy Match the exact policy name from Wiz
9 Computed value misconfig missed Scanned raw HCL, not the plan Bad value is a Terraform variable/expression Scan terraform show -json plan output
10 Secret “removed” but still leaked Deleted the commit, didn’t rotate Key still valid against the cloud provider Rotate the key; purge history
11 Intermittent scan failures wizcli@latest changed exit semantics Failures started after a CLI auto-update Pin WIZCLI_VERSION to a known-good version
12 .wizignore silently hollows the gate Un-reviewed, never-expiring ignores accumulate Many entries, no reasons/expiries CODEOWNERS .wizignore → security; lint expiries
13 Later scans skipped after one fails All scans in one run: with set -e First failure aborts the shell step One scan per step (see exemplar YAML)
14 Image scanned ≠ image shipped Scanned a mutable tag, pushed a different build Digest in scan ≠ digest pushed Scan by digest; bind verdict to it
15 Gate blocks during a Wiz outage Fail-closed with Wiz unreachable wizcli network error, all PRs red Expected — use a logged, time-boxed break-glass only

The most common single mistake by far is #5 / #6 — turning the gate on at full strictness against a brownfield repo. The gate is technically working; it is socially failing, because it blocks debt no PR created (fix with baselining) or blocks CVEs no developer can patch (fix with fixable-scoping). Get those two right and adoption succeeds; get them wrong and the team disables the gate within a week, which is worse than no gate because it teaches that security controls are obstacles.

Best practices

Security notes

The entire premise is shift-left: stop the misconfiguration and the vulnerable image at the pull request, before they reach a cloud account, become reachable, or enter the code-to-cloud graph as a live attack path. But pre-merge scanning is the first control, not the only one, and treating it as complete is itself a risk. Three security properties deserve emphasis.

First, credential hygiene is as important as the scan. Leasing the Wiz secret from HashiCorp Vault via GitHub OIDC with a short TTL, and binding issuance to your repo and PR refs via bound_claims, means there is no static, long-lived Wiz key in GitHub to leak, and a fork cannot mint the credential. The service account is scoped to CI/CD scanning only — the pipeline never needs (and must never hold) your full cloud-inventory scope, because a leaked scanning credential should not be a leaked estate.

Second, a secret found in a PR is already compromised. The gate blocking a verified secret is the detection; the response is rotation, not deletion. A key that was pushed to GitHub’s servers is exposed regardless of whether you later remove the commit — assume it is public, rotate it, then purge history. This is why the secrets policy is the strictest (fail on any verified secret) and why it dovetails with a dedicated remediation runbook.

Third, the runtime layers stay on the job after the artifact ships. Wiz continues agentless CSPM of the running estate so drift introduced outside the pipeline (a console edit, a break-glass change, a resource created by another tool) is still caught — the PR gate governs code-driven change, not every change. Admission control re-checks the digest-bound verdict at the cluster door so an image that skipped CI is stopped at deploy. And a runtime sensor (the Wiz Sensor, and/or CrowdStrike Falcon) provides threat detection on the live workload — the layer that watches for exploitation of whatever did ship. The security posture is the whole stack; Wiz Code is the earliest, cheapest layer, not a substitute for the rest.

The security-control layering, and what each is authoritative for:

Control Authoritative for Blind to
Wiz Code PR gate Code-driven misconfig/vuln/secret pre-merge Drift outside the pipeline; post-deploy CVEs
Registry scan Vulns discovered after build, on stored images Images never pushed to that registry
Admission control Unscanned/failing images at the cluster door Non-K8s workloads
Agentless CSPM The running cloud posture + drift In-workload runtime behavior
Runtime sensor (Wiz/Falcon) Exploitation of the live workload Pre-deploy issues (that’s the PR gate’s job)

Cost & sizing

Wiz Code scanning is licensed within your Wiz subscription, so the marginal cost of adding these gates is dominated not by per-scan fees but by CI minutes and the (fixed) Wiz platform cost you already carry. The per-PR budget breaks down roughly as follows — the numbers are indicative and vary with image size and repo:

Cost component Typical magnitude Driver Reduce it by
IaC scan ~10–40 s Number of IaC files Point --path at the IaC dir, not the whole repo
Secrets/dir scan ~15–60 s Repo + history size fetch-depth tuned; scan history less often
Dockerfile scan ~5–15 s Trivial (static) Negligible; keep it
docker build 1–5 min Image size + layer cache Layer caching (docker/build-push-action, cache)
Image scan (+ SBOM) ~20–90 s Image size + package count Smaller base image; fewer packages
Total added per PR ~2–7 min Dominated by docker build, not the scans Cache the build; trigger only on PR-to-main

The economics favor the gate heavily. A few CI minutes per PR is trivial against the fully-loaded cost of a misconfiguration discovered in production — the incident bridge, the change-freeze, the forensics if it was reachable, the rebuild and redeploy, and the reputational and regulatory exposure of, say, a public bucket of customer data. Northwind’s scenario put a number on the time saved (11 hours → under 2 minutes mean-time-to-fix per caught finding); the cost saved is the incident that never happens.

Keep the CI bill small with three levers: trigger scope (run on pull_request to main only, not every push to every branch — this is the single biggest saver), build caching (the scans are cheap; the docker build they depend on is not — cache layers and Terraform providers so the build is fast), and single-job execution (run all four scans in one job to avoid re-checkout and re-auth overhead across jobs). Rough figures: on GitHub-hosted ubuntu-latest runners, an added ~3–5 minutes per PR at 40 PRs/week is ~10–13 hours of runner time monthly — well within a standard Actions allowance, and negligible against even one avoided incident. Self-hosted runners drop the marginal minute cost to near zero if you already run a fleet; the trade-off is maintaining the runners (see the self-hosted-runner economics in your CI platform’s sizing).

Interview & exam questions

Q1. Why scan the Dockerfile and the built image rather than just one? The Dockerfile scan is static and pre-build — it catches :latest base pins, ADD from a URL, secrets in ENV, and a missing non-root USER before any build cost. The image scan runs after docker build and inspects the resolved layers — the actual base the tag pointed to and the OS/language CVEs that apt/pip/npm pulled in. A clean-looking Dockerfile can resolve to a base carrying a Critical CVE; they are complementary finding sets, so you run both.

Q2. What actually makes the pipeline block a merge, mechanically? wizcli … --policy <name> evaluates the scan against a named Wiz CI/CD policy and exits non-zero when the policy is violated. That non-zero exit fails the GitHub step, fails the job, turns the wiz-scan required status check red, and — with branch protection making it required and enforce_admins=true — GitHub refuses the merge. The exit code is the enforcement; the policy is the contract.

Q3. Why fail on fixable Critical CVEs rather than all Criticals? Blocking a build on a Critical with no available patch gives the developer no action to take, so the gate becomes an obstacle they learn to route around — which destroys the control. Scoping the vuln policy to fixable Criticals (plus malware, always) keeps every block actionable; unfixable Criticals are surfaced as warnings and tracked via VEX until a patch ships.

Q4. A team turns the gate on and every PR blocks on day one. What went wrong and how do you fix it? The gate is enforcing against pre-existing findings that no individual PR created — brownfield debt. The fix is baselining: mark the current findings as the accepted starting set so the gate fails only on new findings a PR introduces, then burn the backlog down on a schedule. Block regressions, not debt.

Q5. Explain how the code-to-cloud graph changes prioritization versus a flat CVSS list. A flat scanner produces a list keyed on CVE severity, which drowns teams in “Criticals” that are unreachable or theoretical. The graph joins each finding to the cloud resource it becomes, that resource’s network exposure, the identity it can assume, the data it can reach, and (via the sensor) whether it is actually running and loaded — so the gate can distinguish a genuine internet-exposed attack path from an unreachable theoretical CVE, and gate accordingly.

Q6. What does the Wiz Sensor add, and is it required for the PR gate? The Wiz Sensor is an eBPF runtime agent that feeds “is this actually running / loaded / exposed” signal into the graph, which sharply improves prioritization (a CVE in never-loaded code is deprioritized; one in a loaded, exposed, privileged workload is escalated). It is not required to gate PRs — Wiz Code scans and enforces policy without it — but where present it turns raw findings into ranked attack paths.

Q7. Why lease the Wiz credential via OIDC instead of a GitHub secret? A static GitHub secret is long-lived material sitting in the platform; if the repo or the secret store is compromised, the key leaks. OIDC leases the credential at run time from a broker (Vault or a cloud secrets manager) with a short TTL and claims bound to your repo and PR refs, so nothing long-lived lives in GitHub and a fork cannot mint it. The Wiz secret never touches the repo.

Q8. A secret is caught in a PR. Is deleting the commit sufficient? No. A secret pushed to GitHub’s servers is compromised the moment it lands — it may be in forks, caches, and the reflog. Deleting the commit removes it from the diff but not from exposure. The correct response is to rotate the key (invalidate it at the provider), then purge history. Detection is the gate’s job; rotation is the response.

Q9. Why scan the image by digest rather than by tag? A tag is mutable — the tag you scanned in CI can point to a different build than the one you push, so the verdict would not describe the shipped artifact. Scanning by digest binds the scan to the exact immutable image, so the PR verdict, the Wiz console record, and any admission-time re-check all reference the same bytes.

Q10. What is admission-style enforcement and what gap does it close? CI gates code-driven change at the PR, but images can reach a cluster by paths that skipped CI, and stored images grow new CVEs after they were built. Admission control re-checks the digest-bound Wiz verdict at deploy time and refuses to admit a pod whose image has not passed policy — closing the gap between “passed CI once” and “is being deployed now.”

Q11. Where does Wiz Code stop and runtime tools take over? Wiz Code is the pre-merge, code-security layer — it stops misconfig/vuln/secret findings in the PR. Once the artifact ships, agentless CSPM watches the running posture for drift introduced outside the pipeline, admission control gates deploys, and runtime sensors (the Wiz Sensor and/or CrowdStrike Falcon) detect exploitation of the live workload. Shift-left moves detection earlier; it does not replace the layers that watch what runs.

Q12. How do you keep .wizignore from hollowing out the gate? Require a reason and an expiry on every entry, put the security team on CODEOWNERS for the file so devs cannot self-approve weakening the gate, add a CI lint that flags expired or expiring entries, prefer narrow paths over broad globs, and track every ignore as debt to be fixed rather than a permanent suppression. Fix beats ignore.

Quick check

  1. Which wizcli flag turns a scan into a gate — i.e., makes the CLI exit non-zero on a violation?
  2. You see a red wiz-scan check but the PR still merges. Name the two most likely misconfigurations.
  3. Why does the recommended vulnerability policy fail on fixable Criticals but only warn on unfixable ones?
  4. A brownfield repo blocks every PR on day one. What is the mechanism that makes the gate adoptable?
  5. Name the runtime component that feeds reachability context into the graph, and state whether it is required to gate PRs.

Answers

  1. --policy "<name>". It evaluates the scan against a named Wiz CI/CD policy and exits non-zero when that policy is violated; the non-zero exit fails the step and the job.
  2. The check is not marked required in branch protection, and/or enforce_admins=false so admins bypass it. Make it a required status check and set enforce_admins=true.
  3. Blocking on an unfixable Critical gives the developer no action to take, so they learn to route around the gate; scoping to fixable Criticals keeps every block actionable while unfixable ones are tracked as warnings/VEX.
  4. Baselining — mark the current findings as the accepted starting set so the gate fails only on new findings a PR introduces, blocking regressions rather than pre-existing debt.
  5. The Wiz Sensor (eBPF runtime agent). It is not required to gate PRs — Wiz Code scans and enforces policy without it — but it sharply improves prioritization by adding runtime reachability to the graph.

Glossary

Next steps

WizWiz CodeGitHub Actionswiz-cliIaC scanningContainer scanningASPMDevSecOps
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading