Skip to content
Visibility internal Owner _ Approver _ Created _ Updated _

Running in CI

Two different cadences. Static scans belong on every pull request; live scans belong on a schedule against staging, because they are slow and they touch a running system.

Static scans on pull requests

.github/workflows/security-static.yml:

name: security-static
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  security-events: write   # required to upload SARIF

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # gitleaks needs full history

      - name: Install scanners
        run: |
          pip install semgrep checkov
          curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
            | sudo sh -s -- -b /usr/local/bin
          # gitleaks: install from its releases page

      - name: Run static scans
        run: ./.claude/skills/security-scan/scripts/scan-static.sh --out results

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

Notes:

  • fetch-depth: 0 matters. Without full history, Gitleaks only sees the tip commit and will miss secrets committed earlier.
  • if: always() on the upload step means findings still get reported when an earlier step exits nonzero.
  • Uploading SARIF to the code-scanning tab works on public repos and on private repos with GitHub Advanced Security. Without it, keep the SARIF as a build artifact instead.
  • Start without --fail-on-critical. Run it advisory for a couple of weeks, clear the baseline, and only then make it blocking — otherwise the first red build teaches everyone to ignore it.

Live scans on a schedule

name: security-live
on:
  schedule:
    - cron: '0 3 * * 1'      # Mondays, 03:00 UTC
  workflow_dispatch:          # and on demand

jobs:
  dast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Baseline scan against staging
        run: |
          ./.claude/skills/security-scan/scripts/scan-live.sh \
            --target "${{ vars.STAGING_URL }}" --out results
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: dast-results
          path: results/

The scope file (.security-scan/scope.txt) must be committed for this to run, which is intentional — the set of hosts CI may scan should be visible in code review rather than buried in a secret.

Never point the scheduled job at production, and never add --active to it.

Feeding results back to Claude

The reason to keep SARIF as an artifact even when the code-scanning tab is available: you can download it and hand it straight to Claude Code with the repo checked out, which is where triage actually happens. The CI job finds things; it doesn’t tell you which of them are real.