Security and trust boundaries¶
api-contract is a developer and CI tool. This page is its threat model: what
it trusts, what it treats as untrusted data, and how to run it safely in CI.
To report a vulnerability, see
SECURITY.md.
What is trusted¶
| Input | Trust | Why |
|---|---|---|
The configured source (fastapi:, flask:, flask-routes:, django, file:module:callable) |
Code execution | Loaders import your application to generate its schema, exactly like your test suite does. |
pyproject.toml / api-contract.toml |
Trusted | It chooses what code is imported. |
The install and args inputs of the GitHub Action |
Trusted | install is run by bash. They come from the workflow file, not from a pull request. |
git: sources and --against REV |
Trusted repository, any revision | Read with git show; the revision is passed as one argument and may not start with -. |
Anyone who can change the configured source, or the code it imports, can run
code wherever api-contract runs. That is the same trust you give your test
suite, and it sets the main rule for CI below.
What is untrusted data¶
These are parsed as data and never executed:
- Spec files (
file:openapi.yaml,diff OLD NEW, partner or legacy specs, Swagger 2.0): parsed with a safe YAML loader (no Python objects), with a limit on alias-expanded size and a refusal of self-referencing aliases, so a "billion laughs" document fails fast with exit code 2. External$refs are recorded, never fetched: no network access, no reads outside the given file. - The allowlist (
api-contract-allow.yml): same YAML guards; duplicate keys are rejected. - Traffic samples (
--usage,verify-samples): read line by line as text or JSON, matched against path templates; nothing in them is evaluated. - Every string that ends up in a report (paths, enum values, defaults,
allowlist reasons): control characters are escaped in text reports, so a
value can neither start a fake
::error::workflow command in a CI log nor send terminal escape sequences. In Markdown reports (pull request comments) prose is escaped so it can't inject HTML or links, and@mentionsare broken so a crafted value can't notify people. GitHub annotations escape values as the workflow-command format requires; JUnit and SARIF are written with an XML and a JSON serializer. - Deeply nested documents that exceed Python's recursion limit end with a clean error (exit code 2), not a crash.
Running it in CI safely¶
- Run the check on
pull_request, never onpull_request_targetor any trigger that checks out a fork's code with a write token or secrets: the loader imports that code. - Give the job
permissions: contents: read. The PR comment of the bundled action needspull-requests: write(orissues: write); on pull requests from forks GitHub hands out a read-only token, and the action then only writes the job summary. - The bundled action passes every
${{ }}value through environment variables, never into a script body, and pins its own dependencies by commit. - Pin
api-contract(version: "==X.Y.Z"in the action, or a lock file) so a CI run uses the release you reviewed. Releases are published from tags with PyPI trusted publishing and carry build provenance attestations.
Files it writes¶
The snapshot, the allowlist, reports (--output) and init's files. Writes go
to a temporary file in the same directory that then replaces the target, so a
crash or a parallel job never leaves a half-written file. init never
overwrites an existing file.
How the project checks itself¶
CI runs ruff's bandit rules (S), pip-audit on a full install, CodeQL
(security-extended queries) weekly and on every pull request, and mypy. All
GitHub Actions are pinned by commit SHA and updated by Dependabot. Security
fixes are listed under Security in the changelog, each with a regression
test in tests/test_security.py.