Run vet on every push or PR to catch security regressions before they ship.

GitHub Actions

Basic workflow

# .github/workflows/security-scan.yml
name: Security Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  vet:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Cache vet binary
        id: cache-vet
        uses: actions/cache@v4
        with:
          path: ~/.bun/bin/vet
          key: vet-${{ hashFiles('vet/bun.lockb', 'vet/src/**') }}

      - name: Build vet
        if: steps.cache-vet.outputs.cache-hit != 'true'
        run: |
          cd vet
          bun install
          bun build src/cli.ts --compile --outfile vet
          mkdir -p ~/.bun/bin
          cp vet ~/.bun/bin/vet

      - name: Run security scan
        run: |
          ~/.bun/bin/vet https://staging.your-app.com \
            --format json > scan-results.json

      - name: Check for failures
        run: |
          FAIL_COUNT=$(jq '.result.summary.fail' scan-results.json)
          echo "Failures: $FAIL_COUNT"
          if [ "$FAIL_COUNT" -gt 0 ]; then
            echo "::error::Security scan found $FAIL_COUNT failing checks"
            jq '.result.categories | to_entries[] | select(.value.status == "fail") | .key' scan-results.json
            exit 1
          fi

      - name: Upload scan results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: vet-scan-results
          path: scan-results.json

Exit codes

Vet always exits 0 and puts the result in JSON. The scan status lives in the JSON envelope, not the process exit code. This is by design — it lets you parse the output and decide what constitutes a failure for your project.

To fail CI on findings, check the JSON:

# Fail on any failing check
FAIL_COUNT=$(vet https://your-app.com | jq '.result.summary.fail')
[ "$FAIL_COUNT" -gt 0 ] && exit 1

# Fail only if specific categories fail
vet https://your-app.com > results.json
HEADERS_STATUS=$(jq -r '.result.categories.security_headers.status' results.json)
TLS_STATUS=$(jq -r '.result.categories.tls_ssl.status' results.json)

if [ "$HEADERS_STATUS" = "fail" ] || [ "$TLS_STATUS" = "fail" ]; then
  echo "Critical category failed"
  exit 1
fi

If vet itself fails (target unreachable, invalid URL), the envelope has ok: false:

OK=$(vet https://your-app.com | jq '.ok')
if [ "$OK" != "true" ]; then
  echo "Scan failed to run"
  exit 1
fi

Parse JSON output for specific categories

Fail on security headers and TLS only (allow warnings in other categories):

#!/bin/bash
set -euo pipefail

vet https://staging.your-app.com > results.json

# Critical categories that must pass
CRITICAL_CATS=("security_headers" "tls_ssl" "input_validation")

for cat in "${CRITICAL_CATS[@]}"; do
  STATUS=$(jq -r ".result.categories.${cat}.status // \"missing\"" results.json)
  if [ "$STATUS" = "fail" ]; then
    echo "FAIL: $cat"
    jq ".result.categories.${cat}.checks[] | select(.status == \"fail\")" results.json
    exit 1
  fi
done

echo "All critical categories passed"

Extract individual failing checks

# List all failing checks with their remediation
vet https://your-app.com | jq '[
  .result.categories[]
  | .checks[]
  | select(.status == "fail")
  | {id, name, endpoint, remediation}
]'

Example output:

[
  {
    "id": "V9.1.1",
    "name": "HSTS present",
    "endpoint": "https://your-app.com",
    "remediation": "Add Strict-Transport-Security header with max-age>=31536000."
  },
  {
    "id": "V9.1.6",
    "name": "Permissions-Policy restricts sensitive features",
    "endpoint": "https://your-app.com/api/v1",
    "remediation": "Permissions-Policy should restrict camera, microphone, geolocation."
  }
]

Scan a staging environment before deploy

If your CI deploys to a staging URL, scan it post-deploy:

  deploy-staging:
    runs-on: ubuntu-latest
    outputs:
      staging-url: ${{ steps.deploy.outputs.url }}
    steps:
      - name: Deploy to staging
        id: deploy
        run: echo "url=https://staging-pr-${{ github.event.number }}.your-app.com" >> $GITHUB_OUTPUT

  security-scan:
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - name: Wait for staging
        run: sleep 10

      - name: Run vet
        run: |
          vet ${{ needs.deploy-staging.outputs.staging-url }} > results.json
          FAILS=$(jq '.result.summary.fail' results.json)
          if [ "$FAILS" -gt 0 ]; then
            echo "::error::$FAILS security findings on staging"
            exit 1
          fi

Scan specific categories in CI

Full scans include active probes (injection testing) that send payloads to your app. In CI against a shared staging environment, you may want to run only passive probes:

# Passive probes only — safe for shared environments
vet https://staging.your-app.com --only headers,tls,cors,methods,errors

# Full scan including injection — use on isolated environments
vet https://staging.your-app.com

Cache the binary

The compiled vet binary is ~50MB. Cache it by the lockfile hash:

- uses: actions/cache@v4
  with:
    path: ~/.bun/bin/vet
    key: vet-${{ hashFiles('vet/bun.lockb', 'vet/src/**') }}

This avoids rebuilding on every run. The cache invalidates when source or dependencies change.

GitLab CI

security-scan:
  image: oven/bun:latest
  stage: test
  script:
    - cd vet && bun install && bun build src/cli.ts --compile --outfile vet
    - ./vet https://staging.your-app.com > results.json
    - FAILS=$(cat results.json | jq '.result.summary.fail')
    - if [ "$FAILS" -gt 0 ]; then echo "Security scan failed"; exit 1; fi
  artifacts:
    paths:
      - results.json
    when: always