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, 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

Target topology

Integrate Wiz Code into GitHub Actions for IaC and Container Scanning Gates — 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:

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-:latest base, a hardcoded secret, an ADD from 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 your apt-get install just 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:

  1. Open a PR that introduces that bad bucket → confirm the wiz-scan check goes red, the finding shows inline on the diff, and Merge is blocked.
  2. Push a fix in the same PR → confirm the check flips green and merge unblocks.
  3. Confirm the scan appears in the Wiz console under the build name, and the SBOM artifact is attached to the workflow run.
  4. 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

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.

WizGitHub ActionsTerraformContainersDevSecOpsShift-left
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