Scheduled security scans with evidence and PR gates
PLA 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.
What a scheduled scan setup gives you
Section titled “What a scheduled scan setup gives you”- 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.
Which layer catches which vulnerability?
Section titled “Which layer catches which vulnerability?”The agent pass does not replace the scanners. Each layer produces a different kind of evidence, and triage needs all of it.
| Layer | Finds | Evidence it must leave |
|---|---|---|
| SAST (Semgrep) | Known insecure code patterns in one file or function | Tool version, rule ID, file and line (SARIF) |
| Secret scanning (Gitleaks) | Keys and tokens in the working tree and git history | Tool version, rule ID, commit, redacted match |
| Dependency scanning (OSV-Scanner) | Packages with published advisories | Tool version, advisory ID, lockfile path |
| Agent pass (read-only) | Missing authorization, cross-tenant reads, unsafe state transitions, business-logic gaps that span files | Call path through concrete symbols, missing evidence, a proposed reproduction test |
| Human triage | Exploitability, business impact, priority | Accept or reject, owner, rationale, expiry date |
| Regression suite | The same vulnerability coming back | A 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.
How to set up the weekly scan
Section titled “How to set up the weekly scan”-
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 -uout=security-outmkdir -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 1for f in semgrep gitleaks osv; do test -s "$out/$f.sarif" || exit 1; doneThe 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-lockfilesonly to a repository that genuinely has no dependencies. Run the script withbash scripts/security/run-scanners.sh, orchmod +xit once and commit the mode. -
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: falseand lists all its properties as required, so the same file works for Claude Code’s--json-schemaand Codex’s--output-schema. -
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.
-
Fingerprint and deduplicate before anyone reads the output. Compute a fingerprint as a hash of
category,file, andsymbol, 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 writessecurity-out/fingerprints.txtwith thisjqandsha256sumloop.Terminal window jq -r '.findings[] | "\(.category)|\(.file)|\(.symbol)"' security-out/agent-findings.json |while read -r k; doprintf '%s %s\n' "$(printf '%s' "$k" | sha256sum | cut -c1-8)" "$k"done > security-out/fingerprints.txt -
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_sharecords the commit the decision was made against: a reviewer reopens the finding whengit log -L :<symbol>:<file> <code_sha>..HEADor a PR diff shows the symbol changed, or when the disposition expires.security/dispositions.yaml - fingerprint: 3f9c1e07decision: accepted # accepted | rejected | risk-acceptedowner: security-owner-paymentsrationale: "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 againstexpires: 2026-12-21 -
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.mdand re-enters the artifact chain instead of growing inside one PR. -
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.
name: weekly-security-scanon: schedule: - cron: "17 3 * * 1" # Mondays 03:17 UTC workflow_dispatch:permissions: contents: readjobs: 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.
The official action runs codex exec with a permission profile. :read-only blocks file writes and direct network access from the agent’s commands. The job then validates the output file against the same schema. Inputs checked against openai/codex-action v1.12 and flags against codex exec --help 0.157.1.
agent-pass: runs-on: ubuntu-latest timeout-minutes: 45 steps: - uses: actions/checkout@v7 with: { persist-credentials: false } - run: mkdir -p security-out - uses: openai/codex-action@v1 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} permission-profile: ":read-only" prompt-file: .github/security/scan-prompt.md output-schema-file: .github/security/findings.schema.json output-file: security-out/agent-findings.json model: gpt-6-astra - run: printf 'commit=%s\nmodel=gpt-6-astra\n' "$GITHUB_SHA" > security-out/run-meta.txt - run: npx --yes ajv-cli@5.0.0 validate -s .github/security/findings.schema.json -d security-out/agent-findings.json # then the same "Fingerprint findings" step as in the Claude Code job - if: always() uses: actions/upload-artifact@v7 with: { name: agent-findings, path: security-out/ }To try the same pass locally before you schedule it, run this in the repository root. The </dev/null matters in scripts: codex exec otherwise waits for more input on stdin.
codex -c default_permissions=":read-only" exec --ephemeral \ --output-schema .github/security/findings.schema.json \ -o security-out/agent-findings.json \ "$(cat .github/security/scan-prompt.md)" </dev/nullCodex also offers Automations (scheduled tasks in the app), @codex security review on GitHub pull requests, and Codex Security, an application security agent for finding, confirming, and fixing vulnerabilities (vendor docs checked 2026-08-28). See Codex Security for the managed option.
Cursor schedules work through Automations, which start a Cloud Agent from a Scheduled trigger (or source control, Slack, webhooks, Linear, Sentry, or PagerDuty). Put the scan prompt below into the automation and have the agent write security-out/agent-findings.json in the schema above. Cloud Agents run in VMs with full development environments, so before the first run check which secrets that environment holds and what the agent can push. Keep production credentials out of it.
Cursor’s Security Agents “scan your code for security bugs, risky patterns, and vulnerabilities”, and Bugbot reviews pull requests for bugs and security issues. Both were checked against Cursor’s docs on 2026-08-28; later Cursor security features were not verifiable for this page. Whichever you use, feed the output into the same fingerprinting and disposition ledger, so triage does not depend on the tool.
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 -eandajv-clisteps). 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.
Checklist for a maximum Q24 score
Section titled “Checklist for a maximum Q24 score”- 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.mdinstead of growing inside one PR.
Where to go next with security scanning
Section titled “Where to go next with security scanning”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.