A platform-engineering team ships a multi-cloud estate — Terraform for AWS and Azure, a dozen container images on a shared 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: "*". The findings arrive in a weekly Wiz cloud report, by which point the misconfiguration is already running. The ask from the CISO is blunt: “stop merging the problems.” This guide wires Wiz Code — Wiz’s code-and-pipeline security scanner — directly into GitHub Actions so that on every pull request the pipeline scans the Terraform, the Dockerfiles, and the freshly built image, posts findings inline on the PR, and fails the required check when anything critical is found, so the fix happens before merge instead of after deploy. We will also place it correctly in the real operating model: identity, secrets, ticketing, and the runtime tools that take over once the artifact ships.
This is an Advanced, hands-on walkthrough. By the end you will have a reusable workflow, a branch-protection gate, an SBOM artifact, and a teardown path.
Prerequisites
- A Wiz tenant with the Wiz Code module enabled, and permission to create a service account of type CI/CD scanning in Wiz Settings.
- A GitHub organization with GitHub Actions enabled and admin on the target repo (to set branch protection).
- The
wizclibinary available to the runner (we install it in-job; no manual step needed). - Terraform ≥ 1.6 and Docker in the repo’s build; the repo already builds an image today.
- A secrets backend. This guide uses GitHub OIDC → HashiCorp Vault to fetch the Wiz client secret at run time, so no long-lived credential sits in GitHub secrets. A plain encrypted Actions secret works too if you do not run Vault.
- Workforce SSO through Okta federated to Microsoft Entra ID for the humans who read findings in the Wiz console (SAML/OIDC app already configured by your IdP team).
- A ServiceNow instance if you want auto-ticketing of critical findings (optional, Step 7).
Target topology
The flow is a single PR-triggered pipeline with four gates. A developer opens a pull request. 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 wizcli, authenticates to the Wiz tenant, and runs three scans in sequence: an IaC scan over the Terraform, a Dockerfile/directory scan over the build context, and — after docker build — an image scan with SBOM extraction over the actual layers. Each scan uploads its results to the Wiz tenant (so they appear in the same console the security team already uses) and emits a SARIF file that GitHub renders as inline annotations on the PR. A Wiz CI/CD policy decides pass/fail by severity; a critical finding makes the step exit non-zero, the required check goes red, and branch protection blocks the merge. On a clean run the image is pushed, and the baton passes to the runtime side — CrowdStrike Falcon for workload runtime protection and Wiz’s own agentless cloud scanning for posture once the artifact is live — while Dynatrace or Datadog observe the running service. Optionally a critical finding raises a ServiceNow change/incident ticket so security has a record, not just a red check.
Everything below is the concrete wiring for that picture.
1. Create the Wiz Code service account
In the Wiz console, go to Settings → Service Accounts → Add Service Account. Choose type CI/CD (Code/Pipeline scanning) — this scopes the credential to scanning, not to your whole cloud inventory, which is the least-privilege choice. Wiz returns a Client ID and Client Secret; capture them once.
Do not paste these into a workflow file. Store them in HashiCorp Vault so the pipeline leases them at run time:
# 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"
Then configure Vault to trust GitHub’s OIDC issuer, so the runner authenticates with a short-lived JWT instead of a static token. HashiCorp Vault here is the central secrets broker — it issues a short TTL token to the job and the Wiz secret never lands in GitHub:
# Enable JWT auth backed by 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 one repo + the read policy for the Wiz secret
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
If you are not running Vault, skip the lease and instead set repo secrets WIZ_CLIENT_ID and WIZ_CLIENT_SECRET under Settings → Secrets and variables → Actions; the workflow reads either source the same way.
2. Add a Wiz CI/CD policy for the gate
The decision of what fails the build lives in Wiz, not in YAML — so security owns the threshold centrally and can tighten it without a code change. In the Wiz console go to Policies → Create Policy → CI/CD, and create (or confirm) policies for each scan type:
- IaC misconfiguration policy — fail on Critical and High misconfigurations (e.g. public storage, unrestricted security groups, wildcard IAM, unencrypted volumes).
- Vulnerability policy — fail on Critical vulnerabilities that have a fix available; warn on High. (Failing on unfixable criticals only generates noise.)
- Secrets policy — fail on any verified secret found in the build context.
Note each policy’s exact name; you will pass it to wizcli with --policy so the CLI enforces that specific gate. This is the contract: the pipeline asks Wiz “does this pass policy X,” and Wiz’s exit code is the gate.
3. Scaffold the repo for scanning
Add a Wiz ignore file so intentional, risk-accepted findings do not block forever. Keep it small and reviewed — every entry is a documented exception, not a dumping ground:
cat > .wizignore <<'EOF'
# id reason expires
# wiz-iac-aws-s3-public demo bucket, non-prod only 2026-09-01
EOF
Confirm the build is reproducible from the repo root — the workflow runs terraform init against your IaC directory and docker build against your Dockerfile, so make sure both succeed locally first:
terraform -chdir=infra init -backend=false
docker build -t kloudvin/api:dev .
-backend=false lets the IaC scan run without touching remote state — Wiz scans the files, it does not need your backend.
4. Write the GitHub Actions workflow
Create .github/workflows/wiz-code-gates.yml. This is the heart of the integration: it leases the credential, installs wizcli, and runs all three scans, each enforcing its Wiz policy.
name: Wiz Code Security Gates
on:
pull_request:
branches: [main]
permissions:
contents: read
id-token: write # required for GitHub OIDC -> Vault
security-events: write # required to upload SARIF to the PR
pull-requests: write
jobs:
wiz-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Lease the Wiz CI/CD credential from Vault via GitHub OIDC
- 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/latest/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 — Terraform / IaC misconfiguration scan
- 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 to PR
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: wiz-iac.sarif
category: wiz-iac
# GATE 2 — Dockerfile + build-context (secrets, bad base images)
- name: Wiz Dockerfile scan
run: |
wizcli docker scan \
--dockerfile ./Dockerfile \
--name "kloudvin-dockerfile-${{ github.sha }}" \
--policy "Block-Critical-Vulns"
# Build the actual artifact we intend to ship
- name: Build image
run: docker build -t kloudvin/api:${{ github.sha }} .
# GATE 3 — Image layers + SBOM
- name: Wiz image scan (+ SBOM)
run: |
wizcli docker scan \
--image kloudvin/api:${{ github.sha }} \
--name "kloudvin-image-${{ github.sha }}" \
--policy "Block-Critical-Vulns" \
--sbom-format cyclonedx-json \
--sbom-output sbom.cdx.json \
--output sarif,wiz-image.sarif,true
- name: Upload image SARIF to PR
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
Three things make this a real gate, not a report. First, each wizcli ... --policy call exits non-zero when the named Wiz policy is violated, which fails the job — that is the block. Second, the SARIF uploads put every finding inline on the PR diff so the author sees the exact line. Third, the --name tags each scan so results land in the Wiz console under a traceable build identity. The if: always() on the upload steps guarantees findings still post even when an earlier gate fails (otherwise a failing scan would hide its own annotations).
Why scan the Dockerfile and the built image? The Dockerfile scan catches issues statically — a pinned-to-
:latestbase, a hardcoded secret, anADDfrom a URL — before any build. The image scan catches what is actually in the layers after the build resolves the base and installs packages — the CVEs yourapt-get installjust pulled in. They are complementary; skip neither.
5. Make 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, so the scan that ran is the scan of what will actually merge.
If your org standardizes via rulesets instead, create an org ruleset targeting main with a “Require status checks to pass” rule naming wiz-scan; the effect is identical and applies across repos.
6. Wire identity for the humans who triage findings
The pipeline runs headless, but engineers and the security team read and dispositify findings in the Wiz console, and that access must be governed. Configure Wiz SSO under Settings → Single Sign-On against your IdP. Workforce identity flows Okta → Microsoft Entra ID: people sign in with their Okta credentials and conditional-access policies, Okta federates to Entra ID as the token authority, and the resulting group claims map to Wiz roles — so a developer gets read/triage on their projects while only the security team can edit CI/CD policies or approve .wizignore exceptions. This keeps the who-can-weaken-the-gate decision off the engineers who are trying to ship past it.
Okta (workforce IdP, MFA/conditional access)
└── federates (OIDC/SAML) ──► Microsoft Entra ID (token authority, group claims)
└── group "sec-platform" ──► Wiz role: Policy Admin
└── group "app-developers" ──► Wiz role: Project Member (read + triage)
7. (Optional) Auto-ticket critical findings into ServiceNow
A red check is ephemeral. For audit and follow-through, push critical findings into ServiceNow so security has a tracked record. Wiz has a native ServiceNow integration (Settings → Integrations → ServiceNow) that opens an incident or change record when a CI/CD policy fails at Critical — configure it there with your instance URL and a scoped service account. If you prefer to drive it from the pipeline, add a final step that fires only on failure:
- name: Open ServiceNow ticket on gate failure
if: failure()
run: |
curl -s -u "$SN_USER:$SN_PASS" -X POST \
"https://kloudvin.service-now.com/api/now/table/incident" \
-H "Content-Type: application/json" \
-d '{
"short_description": "Wiz Code gate failed on ${{ github.repository }} PR #${{ github.event.number }}",
"urgency": "1",
"category": "security",
"assignment_group": "sec-platform"
}'
Here ServiceNow is the system of record for the security exception/incident, so a blocked merge produces an auditable ticket and an owner, not just a CI log line that scrolls away.
Validation
Prove the gate actually blocks before you trust it. Run the CLI locally against a known-bad fixture and confirm a non-zero exit:
# Authenticate once locally
wizcli auth --id "$WIZ_CLIENT_ID" --secret "$WIZ_CLIENT_SECRET"
# Make an obviously bad Terraform file and scan it
cat > /tmp/bad/main.tf <<'EOF'
resource "aws_s3_bucket" "leak" { bucket = "kv-test-leak" }
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/bad --policy "Block-Critical-High-IaC"; echo "exit=$?"
You expect a non-zero exit= and a Critical/High finding for public S3 exposure. Then validate the full loop:
- Open a PR that introduces that bad bucket → confirm the
wiz-scancheck goes red, the finding shows inline on the diff, and Merge is blocked. - Push a fix in the same PR → confirm the check flips green and merge unblocks.
- Confirm the scan appears in the Wiz console under the build name, and the SBOM artifact is attached to the workflow run.
- Confirm a ServiceNow incident was opened for the failing run (if Step 7 is enabled).
Rollback / teardown
To remove or pause the gate cleanly, in reverse order of how you built it:
# 1. Drop wiz-scan from required checks (un-gate without deleting the workflow)
gh api -X PUT repos/kloudvin/api/branches/main/protection \
-f 'required_status_checks[strict]=true' \
-F 'required_status_checks[contexts][]=' \
-F 'enforce_admins=true' -F 'restrictions=null'
# 2. Disable the workflow (keeps the file, stops runs)
gh workflow disable "Wiz Code Security Gates" -R kloudvin/api
# 3. Revoke the credential path in Vault and delete the role
vault delete auth/github-actions/role/wiz-scan
vault kv metadata delete secret/ci/wiz
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 only need a temporary bypass for one emergency PR, prefer a time-boxed .wizignore entry with an expires date over disabling the whole gate.
Common pitfalls
- Scanning only the Dockerfile, not the built image — you miss the CVEs the base image and
apt/pipinstall resolve to at build time. Run both scans (Step 4). - Forgetting
id-token: write— the OIDC handshake to Vault fails with a confusing 403 and the job dies before any scan. Thepermissions:block is mandatory. - Missing
security-events: write— SARIF upload silently no-ops, so findings never appear on the PR and reviewers think it is clean. - Gate not marked required, or
enforce_admins=false— the check goes red but merge still works; the block is cosmetic. Make it required and bind admins. - Failing on unfixable critical CVEs — the build blocks on vulnerabilities with no available patch and developers learn to bypass the gate. Scope the vuln policy to fix-available criticals.
.wizignoreas a junk drawer — un-reviewed, never-expiring ignores hollow out the gate. Require a reason and an expiry on every entry, and review them.- Pinning
wizcli@latestwith no fallback — a CLI release can change exit semantics. Pin a known version for reproducibility once you have validated it.
Security notes
The whole point of this guide is shift-left: stop the misconfiguration and the vulnerable image at the PR, before they ever reach a cloud account. But pre-merge scanning is the first layer, not the only one. Keep the runtime tools doing their job after the artifact ships: Wiz continues agentless cloud posture (CSPM) scanning of the running infrastructure so drift introduced outside the pipeline is still caught; CrowdStrike Falcon sensors provide runtime threat detection on the hosts and containers and feed your SOC. The credential hygiene matters as much as the scan — leasing the Wiz secret from HashiCorp Vault via GitHub OIDC with a 15-minute TTL means there is no static, long-lived Wiz key in GitHub to leak, and the bound_claims restriction ties the credential to your repo and PR refs so a fork cannot mint it. Least-privilege the Wiz service account to CI/CD scanning only; the pipeline never needs your full cloud-inventory scope.
Cost notes
Wiz Code scanning is licensed within your Wiz subscription, so the marginal cost here is mostly CI minutes: an IaC + Dockerfile + image scan adds roughly one to three minutes to a PR build, dominated by docker build rather than the scans. Keep it cheap by (a) triggering on pull_request to main only, not on every push to every branch; (b) using actions/cache for Terraform providers and Docker layer cache so the build the scan depends on is fast; and © running the three scans in a single job to avoid re-checkout and re-auth overhead across jobs. The economics favor the gate heavily: a few CI minutes per PR is far cheaper than the incident response, rebuild, and redeploy of a public bucket or a critical CVE discovered in production a week later — which is exactly the after-the-fact cost this integration is designed to eliminate.