Imported from schmitthub/prodsec-challenge (
AGENTS.md). Install upstream withnpx skills add schmitthub/prodsec-challenge. Copyright stays with the author.
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this repo is
Senior Product Security Engineer take-home (brief: challenge/candidate-brief.md). app/ is a deliberately vulnerable FastAPI "records API". The deliverable is not a fixed app — it is CI that protects it, one custom detection rule for a class off-the-shelf scanners miss (broken access control / IDOR), a triage writeup, a remediation message, challenge/ai-security-review.md, and an AI-usage note.
Do not patch seeded vulnerabilities in app/ unless explicitly asked. They are the subject of the exercise. Seeded issues (all intentional): IDOR in GET /api/records/{record_id} (app/routes/records.py — no owner check; compare /notes which has one), SQL string interpolation in db.search_records, SSRF in POST /api/webhooks/vendor-preview, verify_exp: False + hardcoded JWT_SECRET in app/auth.py, plaintext passwords in db.USERS, repr(exc) leaked by the global exception handler, fake secrets in config/dev.py and helpers/fixture_secrets.py.
Scenario Boundary (Non-negotiable)
The challenge/ directory contains scenario instructions and submission material; it is not part of the project. No application code, tests, scripts, CI workflows, configuration, scanner policy, or agent skill may import, parse, copy, link to, cite, name, or otherwise depend on anything under challenge/. Never use that directory as a runtime input, policy source, baseline, suppression justification, or project documentation target. If project tooling needs equivalent information, place it in an appropriate project-owned location outside challenge/.
The only permitted acknowledgement of challenge/ outside that directory is this agent-instruction boundary and direct work on the scenario material when the user explicitly requests it. References from project artifacts into challenge/ are always defects; remove the dependency rather than correcting its path.
Commands
pyproject.toml (no [build-system]: uv treats the project as virtual, uv sync installs deps only — the service is run from source) + uv.lock are the dependency source of truth (resolved, transitive, hash-pinned; dev group is ruff only) — SBOM and the osv-scanner pre-commit hook read uv.lock. requirements.txt still exists because README/Dockerfile/test.yml install from it; retiring it in favour of the lock is a triage-writeup recommendation. Security scanners are deliberately not uv deps — they run from pinned revs in .pre-commit-config.yaml (prek-managed envs) so tool requirements can't constrain runtime pins. Python 3.11.
uv sync # runtime + dev group (ruff only; scanners live in pre-commit)
uv run python -m unittest discover -s tests # full suite (what CI runs)
uv run python -m unittest tests.test_records.RecordsApiTests.test_health_check # single test
uv run uvicorn app.main:app --reload # API on :8000, docs at /docs
docker build -t records-api . && docker run --rm -p 8000:8000 records-api
uv run ruff check --fix . && uv run ruff format .
prek run --all-files # ruff, gitleaks, bandit, semgrep (python + actions), osv-scanner, unit tests — scanners from their pinned rev in prek's cache; severity gates match CI (bandit HIGH; semgrep via the shared gate script)
prek run semgrep --all-files # one hook by id
scripts/sarif-scan.sh # full-tree SARIF for every scanner into .sarif/ (gitignored; VS Code SARIF Viewer auto-loads it) — all severities, baselines/gates not applied
Test accounts: alice@example.test/alice-password, bob@example.test/bob-password (members), clinician@example.test/clinician-password (staff). Login POST /api/login → bearer token.
Commit guard: .claude/hooks/git-checks.sh blocks --no-verify, -n, SKIP=, core.hooksPath overrides, and plumbing commits. Pre-commit hooks always run; don't route around them. .codex/hooks/hooks.json mirrors this for Codex.
App architecture
app/main.py—FastAPIapp, mounts four routers under/api,/health, catch-all exception handler.app/auth.py— HS256 JWT issue/verify;get_current_useris the auth dependency every protected route takes viaAnnotated[User, Depends(get_current_user)]. There is no authorization layer — each route does (or fails to do) its own ownership/role check.app/db.py— no real database.USERS/RECORDSdicts;search_recordsbuilds a throwaway in-memory sqlite per call.app/routes/—login,records(/me,/records,/records/{id},/records/{id}/notes),search(/search?q=),webhooks(staff-only outbound GET to caller-supplied URL).app/models.py—User,TokenResponsepydantic models.tests/test_records.py—unittest+fastapi.testclient; module-levellogin()/auth_headers()helpers. Only happy paths are covered; no negative-authz tests.
CI / release layout (.github/workflows/)
pr.yml(pull_request → main) andmain.yml(push main) are thin callers of two reusable workflows; permissions are declared per calling job — reusable workflows do not inherit workflow-level blocks.test.yml: the seeded workflow, untouched apart from SHA-pinning its two actions —pip install -r requirements.txt+unittest. Not a security workflow; leave it alone.security.ymljobs and their gates:semgrep— one run writes SARIF (→ Code scanning, all severities) and JSON; on PRs--baseline-commitlimits findings to the PR's, and onlyERROR|HIGH|CRITICALfail via.github/scripts/semgrep_gate.py— the same script the local semgrep hook runs (one hook, same config list as CI — semgrep scopes rules by language itself) (--reportmode in CI, run-then-gate locally), because semgrep's--severityonly knows INFO/WARNING/ERROR and drops HIGH/CRITICAL rules, andsemgrep cigates ondev.semgrep.actionsmetadata, not severity. Main: upload only.bandit—bandit[toml,sarif]==BANDIT_VERSIONvia pip, full-tree SARIF (→ Code scanning, categorybandit); on PRs a JSON scan ofgit archive <base>feedsbandit -band only new HIGH findings fail. Main: upload only.gitleaks— full history,.gitleaks.toml(addslab-vendor-api-keyrule) + baselinegitleaks-report.json; regenerate the baseline withgitleaks git . --config .gitleaks.toml --redact --report-path gitleaks-report.json(stored redacted — every scan that consumes it must pass--redact, which CI and the prek hook both do; gitleaks comparesMatch/Secretagainst the baseline only when redact is 0), don't use.gitleaksignorefor it.image— callsimage.yml(reusable; inputrelease: false). No registry anywhere — the docker-archive is the artifact. Jobs:build—docker build→docker saveinto$RUNNER_TEMP/image/records-api.tar,actions/cachekeyedimage-<sha>(skips the build on a hit; tag runs read main's cache), image ID from the archive'smanifest.json,upload-artifactrecords-api-image(1 day). Commented BuildKit SBOM/provenance attestation block lives here.scan—download-artifact→.github/actions/osv-image-scan(local composite: checksum-verified osv-scanner 2.5.1, onescan image --archiveSARIF scan, uploads all findings under categorycontainer; the entire job is best-effort and cannot block on vulnerabilities, scanner errors, or upload errors).sign—if: inputs.release,needs: build(independent of the advisory scan): gzip torecords-api-<tag>.image.tar.gz,cosign sign-blob --bundle,actions/attestprovenance, self-verify againstimage.yml's identity, uploadrecords-api-image-signed(gz + bundle). Workflow outputsimage-id,signed-artifact,signed-archive.
dependency-review(PRs) — diffs the dependency graph against base, fails on added/changed deps with High+ advisories. No separate lockfile scan job: existing pins are covered by the image scan and Dependabot alerts; the osv-scanner pre-commit hook gatesuv.lockchanges locally.
codeql.yml(push main, PR → main, weekly): advanced setup, matrixpython+actions,config-file: .github/codeql/codeql-config.yml(security-and-qualitysuite,paths-ignorefor tests/challenge/agent dirs — nopaths:allowlist, it would hide workflows from theactionsanalysis). Custom pack skeleton at.github/codeql/custom-queries/(qlpack.yml+ compiling placeholderchallenge.ql); its- uses:line in the config is commented out until a real query lands.release.yml(push tagv*):validate(semver regex, tag must be ancestor oforigin/main) →image(image.yml withrelease: true: build → best-effort scan + sign) →build.yml(reusable, inputsimage-artifact/image-archive/image-idfrom image.yml outputs):git archiveof the tag → download the signed image set, copy the gz intorelease-subjects/→ syft SPDX+CycloneDX SBOMs ofuv.lock→SHA256SUMS(covers the image gz) →cosign sign-blobbundles for everything except the image gz, then copy in image.yml's bundle →actions/attestv4 (provenance mode over the source archive, SBOMs and SHA256SUMS — not the image, which image.yml attested; then SBOM mode withsbom-path) → self-verify (gh attestation verifyandcosign verify-blobper file, identitybuild.ymlorimage.yml) →gh release create. The image is shipped only as a docker-archive (no registry push). Signing identity is anchored tobuild.yml's path:gh attestation verify <artifact> --owner schmitthub --signer-workflow schmitthub/prodsec-challenge/.github/workflows/build.yml.buildjob needscontents: write,id-token: write,attestations: write,artifact-metadata: write; theimagejob needssecurity-events: writeplus, on releases,id-token/attestations/artifact-metadata: write.- Tool versions pinned in two places that must match:
security.yml(semgrep image tag,BANDIT_VERSION,GITLEAKS_VERSION) /.github/actions/osv-image-scan/action.yml(OSV_SCANNER_VERSION) and the corresponding pin in.pre-commit-config.yaml(hookrev, oradditional_dependenciesfor the local semgrep hook). Dependabot bumps neither —prek auto-updateforrevs, edit the semgrep pin by hand. - Actions are SHA-pinned with
# vX.Y.Zcomments; dependabot groups actions/pip/docker weekly. - GitHub rulesets (immutable tags, trunk-based) are configured server-side, not in repo.
prek run --all-filesalso runs ruff--fix/format and whitespace fixers over the seededapp/,tests/,helpers/,README.mdand rewrites them — revert those (git checkout -- app tests helpers README.md) unless a formatting commit is intended.
Agent environment
Runs inside a clawker container with a path-scoped egress firewall (.clawker.yaml). The image ships only CPython 3.14; uv fetches the project's 3.11 and Serena's pyright launcher's 3.13 (uvx -p 3.13) on demand into UV_PYTHON_INSTALL_DIR=/home/clawker/.local/share/uv/python (set via agent.env — the stack's default dir is root-owned and not writable by the agent user; see schmitthub/clawker#506). .venv is tmpfs-masked per .clawkerignore — empty on every container start; run uv sync first.