Skip to content

A scheduled security scan pairs pinned deterministic scanners (SAST, secrets, dependencies) with a read-only agent pass that traces authorization, tenant isolation, and state transitions across files. Both write versioned evidence. A named owner triages each finding by fingerprint, and accepted fixes ship only as pull requests that start with a failing regression test.

Your dependency bot opens version-bump PRs every week, and last year’s penetration test came back clean. Then a customer reports that changing an ID in a URL shows another tenant’s invoices: an authorization gap no pattern-based rule could see, because the missing check spans three files. This page is for the developer who builds the scheduled scan and the tech lead who owns triage and signs off the fixes.

Scorecard Q24: How do you scan the codebase for deep vulnerabilities and architectural drift?

Maximum-score answer (3 points): Scheduled scanner and agent passes retain evidence, route suggested patches through PR gates, and add regression evals for accepted fixes.

  • A scanner script that pins Semgrep, Gitleaks, and OSV-Scanner and records each tool’s version and exit code.
  • A findings schema that forces the agent to cite a file, a symbol, a call path, and the evidence it lacks.
  • A weekly job for Claude Code or Codex (and the Cursor equivalent) that cannot edit files or reach production.
  • A disposition ledger keyed by fingerprint, so a rejected finding does not come back every Monday.
  • A test-first remediation route in which every accepted fix becomes a permanent regression test.

The agent pass does not replace the scanners. Each layer produces a different kind of evidence, and triage needs all of it.

LayerFindsEvidence it must leave
SAST (Semgrep)Known insecure code patterns in one file or functionTool version, rule ID, file and line (SARIF)
Secret scanning (Gitleaks)Keys and tokens in the working tree and git historyTool version, rule ID, commit, redacted match
Dependency scanning (OSV-Scanner)Packages with published advisoriesTool version, advisory ID, lockfile path
Agent pass (read-only)Missing authorization, cross-tenant reads, unsafe state transitions, business-logic gaps that span filesCall path through concrete symbols, missing evidence, a proposed reproduction test
Human triageExploitability, business impact, priorityAccept or reject, owner, rationale, expiry date
Regression suiteThe same vulnerability coming backA test that failed before the fix and passes after

Penetration tests and threat modelling stay on your calendar. The scheduled scan narrows what they have to look at; it does not stand in for them.

  1. Pin the deterministic scanners. Commit one script that every run uses, so a changed result means changed code, not a changed tool. The versions below were the latest releases on 2026-09-30: every version pin on this page was re-checked that day, after the page’s 2026-09-26 update. The workflow’s install step fetches exactly these and verifies each binary’s SHA-256 against the release checksum file; bump them only in their own pull request (see the failure modes below).

    scripts/security/run-scanners.sh
    #!/usr/bin/env bash
    # Pinned: semgrep 1.178.0, gitleaks 8.30.1, osv-scanner 2.6.0 (latest releases, re-checked 2026-09-30)
    set -u
    out=security-out
    mkdir -p "$out"
    : > "$out/exit-codes.txt"
    echo "commit=${GITHUB_SHA:-$(git rev-parse HEAD)}" > "$out/run-meta.txt"
    { semgrep --version; gitleaks version; osv-scanner --version | head -1; } > "$out/tool-versions.txt"
    semgrep scan --config p/default --metrics=off --error --sarif-output="$out/semgrep.sarif"
    echo "semgrep=$?" >> "$out/exit-codes.txt"
    gitleaks git --redact --report-format sarif --report-path "$out/gitleaks.sarif"
    echo "gitleaks=$?" >> "$out/exit-codes.txt"
    osv-scanner scan source -r --format sarif --output-file "$out/osv.sarif" .
    echo "osv=$?" >> "$out/exit-codes.txt"
    # 0 = clean, 1 = findings; any other code = the tool failed
    # (127 = not installed; osv-scanner 128 = no lockfile or manifest found)
    awk -F= '$2>1{bad=1} END{exit bad}' "$out/exit-codes.txt" || exit 1
    for f in semgrep gitleaks osv; do test -s "$out/$f.sarif" || exit 1; done

    The script records exit codes instead of stopping at the first non-zero one. A scheduled run collects evidence; the pull request gates are where a finding blocks a merge. The last two lines separate findings from failures: exit code 1 means findings and keeps the job green, while any other non-zero code or a missing SARIF file fails the job, so an empty report never passes as a clean week. Two tool details make that hold. Semgrep exits 0 even when it finds something unless you pass --error, which is why the script sets it. OSV-Scanner exits 127 on a general error (an unreachable vulnerability database included) and 128 when it finds no lockfile or manifest to scan; both fail the job on purpose, because a dependency scan that saw no packages is not a clean result. Add --allow-no-lockfiles only to a repository that genuinely has no dependencies. Run the script with bash scripts/security/run-scanners.sh, or chmod +x it once and commit the mode.

  2. Define what a finding must contain. The schema is the contract between the agent and your triage. It caps the run at 10 findings and requires the call path and the missing evidence, which keeps low-confidence narratives out of the queue.

    .github/security/findings.schema.json
    {
    "type": "object",
    "additionalProperties": false,
    "required": ["findings"],
    "properties": {
    "findings": {
    "type": "array",
    "maxItems": 10,
    "items": {
    "type": "object",
    "additionalProperties": false,
    "required": ["category", "file", "line", "symbol", "call_path", "missing_evidence", "reproduction_test", "severity_guess"],
    "properties": {
    "category": { "enum": ["authorization", "tenant-isolation", "injection", "secret-exposure", "unsafe-state-transition", "other"] },
    "file": { "type": "string" },
    "line": { "type": "integer" },
    "symbol": { "type": "string" },
    "call_path": { "type": "array", "items": { "type": "string" } },
    "missing_evidence": { "type": "string" },
    "reproduction_test": { "type": "string" },
    "severity_guess": { "enum": ["low", "medium", "high", "critical"] }
    }
    }
    }
    }
    }

    Every object sets additionalProperties: false and lists all its properties as required, so the same file works for Claude Code’s --json-schema and Codex’s --output-schema.

  3. Run the agent pass read-only on a schedule. Give it a fixed prompt from the repository, no write tools, no production credentials, and a time and cost cap. The tabs below show the setup per tool.

  4. Fingerprint and deduplicate before anyone reads the output. Compute a fingerprint as a hash of category, file, and symbol, not the line number, because lines shift on every refactor. Compute the fingerprint in the job, never in the model; the triage step then drops findings with an unexpired disposition in the ledger (step 5). The workflow below writes security-out/fingerprints.txt with this jq and sha256sum loop.

    Terminal window
    jq -r '.findings[] | "\(.category)|\(.file)|\(.symbol)"' security-out/agent-findings.json |
    while read -r k; do
    printf '%s %s\n' "$(printf '%s' "$k" | sha256sum | cut -c1-8)" "$k"
    done > security-out/fingerprints.txt
  5. Triage into a disposition ledger. A named owner records one decision per fingerprint in a reviewed file, so the history travels with the code. The fingerprint deliberately ignores content, so code_sha records the commit the decision was made against: a reviewer reopens the finding when git log -L :<symbol>:<file> <code_sha>..HEAD or a PR diff shows the symbol changed, or when the disposition expires.

    security/dispositions.yaml
    - fingerprint: 3f9c1e07
    decision: accepted # accepted | rejected | risk-accepted
    owner: security-owner-payments
    rationale: "GET /invoices/:id loads by id without a tenant filter"
    evidence: "security-out/agent-findings.json, run 2026-09-21"
    code_sha: 9b1d4e2 # commit the decision was made against
    expires: 2026-12-21
  6. Route each accepted finding through a test-first pull request. The agent writes a failing reproduction test on an isolated branch, then the smallest patch. The PR carries the scanner output, the test, and the CI results, and a security owner approves the merge. Larger remediation becomes an intent.md and re-enters the artifact chain instead of growing inside one PR.

  7. Keep the test forever. The reproduction test joins the regular suite, and the prompt that found the issue becomes a case in your continuous evals, so a later prompt or model change that stops finding it turns red.

Schedule the agent pass in Claude Code, Codex, or Cursor

Section titled “Schedule the agent pass in Claude Code, Codex, or Cursor”

The scanner job and the evidence format are identical across tools. The agent pass differs in how you enforce read-only access and structured output.

In CI, run the CLI headless with only the file-reading tools. --bare, --setting-sources "", and --strict-mcp-config stop the job from loading the repository’s hooks, settings, or MCP servers, which would otherwise run with the job’s API key. Flags checked against claude --help 2.1.285 (npm latest, checked 2026-09-30). claude-opus-5-5 needs v2.1.280 or later, which both release channels have (stable v2.1.292, latest v2.1.302 on 2026-10-17). The job fails when the run reports is_error or returns no structured output, and again when the output does not validate against the schema.

.github/workflows/security-scan.yml
name: weekly-security-scan
on:
schedule:
- cron: "17 3 * * 1" # Mondays 03:17 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
scanners:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false, fetch-depth: 0 }
- name: Install pinned scanners
run: |
cd "$RUNNER_TEMP"
pipx install semgrep==1.178.0
curl -sSfLo gitleaks.tgz https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb gitleaks.tgz" | sha256sum -c -
tar -xzf gitleaks.tgz gitleaks && sudo install gitleaks /usr/local/bin/
curl -sSfLo osv-scanner https://github.com/google/osv-scanner/releases/download/v2.6.0/osv-scanner_linux_amd64
echo "ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108 osv-scanner" | sha256sum -c -
sudo install osv-scanner /usr/local/bin/
- run: bash scripts/security/run-scanners.sh
- uses: actions/upload-artifact@v7
with: { name: scanner-results, path: security-out/ }
agent-pass:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
with: { persist-credentials: false }
- run: npm install -g @anthropic-ai/claude-code@2.1.285
- name: Read-only agent pass
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
mkdir -p security-out
claude -p "$(cat .github/security/scan-prompt.md)" \
--bare --setting-sources "" --strict-mcp-config \
--tools "Read,Grep,Glob" --permission-mode dontAsk \
--model claude-opus-5-5 --max-budget-usd 20 \
--output-format json \
--json-schema "$(cat .github/security/findings.schema.json)" \
> security-out/agent-raw.json
printf 'commit=%s\nmodel=claude-opus-5-5\n' "$GITHUB_SHA" > security-out/run-meta.txt
jq -e '.is_error == false and .structured_output != null' security-out/agent-raw.json >/dev/null
jq '.structured_output' security-out/agent-raw.json > security-out/agent-findings.json
- name: Validate findings against the schema
run: npx --yes ajv-cli@5.0.0 validate -s .github/security/findings.schema.json -d security-out/agent-findings.json
- name: Fingerprint findings
run: |
jq -r '.findings[] | "\(.category)|\(.file)|\(.symbol)"' security-out/agent-findings.json |
while read -r k; do
printf '%s %s\n' "$(printf '%s' "$k" | sha256sum | cut -c1-8)" "$k"
done > security-out/fingerprints.txt
- if: always()
uses: actions/upload-artifact@v7
with: { name: agent-findings, path: security-out/ }

Two first-party alternatives exist. Routines (/schedule, research preview, minimum interval one hour) run the prompt on Anthropic’s cloud on a cadence. A routine has no permission-mode picker and can use every tool of every connector you include, so remove all connectors and keep the prompt read-only. The Claude Security plugin (/plugin install claude-security@claude-plugins-official at the Claude Code prompt) runs a multi-agent scan in your session whose findings are checked by independent verifier agents, and writes SARIF and JSONL results plus a revision stamp recording the scanned commit. Its scans are nondeterministic too, so feed its output into the same fingerprinting and ledger rather than relying on the plugin to recognise a finding it reported last week. It runs under your permissions with the repository’s own settings and hooks, so use it on code you control.

Copy-paste prompts for scheduled security scans

Section titled “Copy-paste prompts for scheduled security scans”

Store the first prompt as .github/security/scan-prompt.md so every run uses the same reviewed text.

Replace 3f9c1e07 with the fingerprint you accepted.

How do you know the scan works without reading every finding?

Section titled “How do you know the scan works without reading every finding?”

You verify the pipeline, not each sentence the agent writes. Five checks carry most of the weight:

  • Schema validation. A failed run, a run without structured output, or output that does not validate against the schema fails the job (the jq -e and ajv-cli steps). Nothing unstructured reaches triage.
  • Evidence stamps. Every run stores tool-versions.txt, exit-codes.txt, run-meta.txt (commit SHA and model ID), and the raw agent output as a build artifact. When a result changes, you can tell whether the code, a scanner, or the model changed.
  • A seeded canary. Keep one branch that reverts a vulnerability you fixed before, such as the missing tenant filter above. Run the agent pass against it monthly and after every prompt or model change. If the pass stops reporting it, recall has regressed; treat that as a failed eval, not as bad luck.
  • Fail-before, pass-after. An accepted fix merges only with a test that failed on the old code in CI. The reviewer checks the test and the CI log, not every line of the patch.
  • Owner sign-off. The code owner for the affected area approves each security PR. For auth, payments, and tenant boundaries, that is a named security owner, per your layered review policy.

Agent scans are nondeterministic: two runs over the same commit can report different findings. The canary and the ledger make that visible and manageable instead of hidden.

What breaks in scheduled security scans, and how to recover

Section titled “What breaks in scheduled security scans, and how to recover”

The agent’s confidence is treated as validation. A finding reads convincingly and someone patches it without a reproduction. Recovery: reject any finding without a call path and a reproduction test, and require the failing test before any fix is reviewed.

The same false positives return every week. Triage fatigue sets in and people stop reading the report. Recovery: fingerprint on category|file|symbol, record rejections with an expiry date in security/dispositions.yaml, and reopen a fingerprint only when the disposition expires or git log -L shows the symbol changed since its code_sha.

The scan job can reach production. A secret in the CI environment or a connector with write access widens the blast radius of a prompt injection hidden in a fixture or issue text. Recovery: remove the credential or connector first, rotate anything the job could read, then tune the prompt. The only secret the agent job holds is its model API key.

A patch weakens a test to make the finding disappear. Recovery: protect test files in review (a CODEOWNERS rule on the test directories works), and reject any security PR that edits an existing assertion without a written reason.

The scan silently stops running. A cron schedule that fails quietly looks like a clean week. Recovery: add a daily job that fails, and so notifies the workflow’s owners, when no successful run exists in the last eight days, for example test "$(gh run list --workflow security-scan.yml --status success --created ">=$(date -d '-8 days' +%F)" --json databaseId -q 'length')" -gt 0, and treat “zero findings” from the agent pass as suspicious until the canary run confirms recall.

Scanner upgrades flood the queue. A new Semgrep ruleset adds a wave of findings overnight. Recovery: bump scanner versions in their own pull request, diff the SARIF against the previous run, and triage the delta as one batch before the weekly job picks it up.

  • Scanners and the agent run are pinned, and each run stores tool versions, exit codes, commit, and model ID.
  • The agent pass cannot write to the repository, load repository hooks, or reach production.
  • Every finding names a file, a symbol, a call path, and its missing evidence.
  • Dispositions live in a reviewed file keyed by fingerprint, with owner and expiry.
  • Accepted fixes merge only with a fail-before, pass-after test and green CI.
  • A named security owner approves merges that touch sensitive boundaries.
  • A seeded canary and a continuous eval case prove the pass still finds known issues.
  • Larger remediation starts as an intent.md instead of growing inside one PR.

The scorecard also asks about architectural drift. Scheduled drift and complexity checks belong on keeping an agent-written codebase healthy. For security checks on every pull request rather than on a schedule, see security scanning and vulnerability testing. The rest of the answer key is on the developer scorecard guide.

Frequently asked questions

Why combine pinned deterministic scanners with a scheduled read-only agent security pass?

Deterministic scanners (SAST, secret detection, and dependency checks) catch known patterns, while a scheduled read-only agent pass traces cross-file authorization, tenant isolation, and state transitions that single-file rules miss.

How are findings from scheduled security scans triaged and fixed?

Both scanner and agent passes write versioned evidence, a named owner triages findings by fingerprint, and accepted fixes ship exclusively through pull requests that begin with a failing regression test.