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