Skip to main content

Tainted

Tainted scans your app for the places where a stranger's data can reach something dangerous: a database query, a shell command, another user's records. Then it proves each hole is real by actually running the attack against your app. Where the fix is safe to make, it writes the fix and runs the attack again to show it now fails.

Tainted only says proven when it actually broke in.

This repository is the engine: a Python library. Every surface under surfaces/ — CLI, CI, MCP, website — is a thin front end over it. None of them reimplements the analysis.

The three registers

  • Structure — static analysis (tree-sitter, Python ast, sqlglot, and Semgrep in taint mode). Reads facts straight from the code: routes, parameters, queries, RLS policies, tool graphs. Fast and exhaustive. It cannot tell what any of it means.
  • Meaning — a language model (Gemini) answers the question static analysis can't: is this id someone's property, and did anyone check ownership?
  • Proof — live execution (httpx, Playwright). A possible hole is not a finding. Tainted confirms it by running the real attack against the app you already run.

The three registers combine differently depending on where the hole is. On the request plane (a URL a stranger can call) Tainted still tries every possible hole the model ranked, because proof only costs one HTTP request — a bad ranking just means a possible hole waits longer, it's still tried. On the dynamic half of the tool plane (an AI agent's tools) the model filters instead, because starting up a live agent to test it is expensive. What the model skips there leaves no trace, so Tainted states plainly how far it reached rather than assuming it reached everything.

Operations

  • analyze(repo) — static, read-only, works on any repo. Returns ranked possible holes.
  • prove(analysis, setup) — runs the running app, fires the real exploit, returns confirmed findings. Gated by ownership checks once the target isn't localhost.
  • fix(finding) — writes the fix and re-checks it with whatever proof that kind of hole supports.

Checks, and how far each one is proven

Check Static Dynamic proof
BOLA Finds routes (Flask, FastAPI, Express, Next App/Pages) + Semgrep taint Account B requests account A's record through the route that leaks it
RLS Checks migrations and client reads against auth.uid() policies Reads a capped number of rows over PostgREST; only counts as proven when a returned row is clearly not the caller's
Classic injection Regex pass, confirmed by Semgrep taint SQL injection is proven live. Command and template injection are demonstrated with a real payload but never executed
Agent injection Reads the tool graph across MCP, n8n, Flowise, LangChain (Python/JS), CrewAI A configured agent is proven in a sandbox with logging-stub tools. A coded agent is reported from the code only, never run
Test integrity — The mutant that survives (via Stryker / mutmut) is itself the proof

Every report says which checks were proven and which were only analyzed. Without that, a report with no proof column reads as a clean bill of health when it might just mean nothing was tried.

Install

pip install tainted-cli          # the local loop: analyze / watch / fix / pre-commit gate
pip install tainted-mcp          # the same operations as tools for a coding agent
pip install "tainted[dynamic]"   # + the browser `prove` drives, for either of the above

Each surface pins the engine, so tainted arrives with it. The CI surface is a GitHub Action rather than a package — surfaces/ci/README.md.

macOS: prove needs host networking. The sandbox container reaches your app on localhost, which on macOS crosses a VM boundary. Docker Desktop 4.34+ can do it (sign in, then Settings → Resources → Network → Enable host networking, then restart) and so can OrbStack. Colima and Podman cannot. Linux needs none of this.

Setup (from a checkout)

pip install -e ".[dev]"          # core engine + tests
pip install -e ".[all,dev]"      # + Playwright, Semgrep, OIDC, DNS
playwright install chromium      # only if you want traffic discovery
cp .env.example .env             # then fill in GEMINI_API_KEY

To run every suite, including the surfaces, install the surface packages too. Each surface has its own dependencies (Typer, the MCP SDK, FastAPI), and the core install does not pull them — without this step the CLI and MCP suites below fail at collection:

pip install -e ".[dev-all]"                      # core + pytest + ruff + mypy
pip install -e surfaces/cli -e surfaces/ci \
            -e surfaces/mcp -e surfaces/website  # the four surfaces

The core goes first and that is now load-bearing: each surface declares tainted==X.Y.Z, so a surface installed into an empty environment fetches the engine from PyPI instead of using the checkout you are editing.

Tainted reads the Gemini key from the GEMINI_API_KEY environment variable or .env. It is never hardcoded. Without it, static analysis still runs in full; only the meaning register is missing, and the report says so instead of hiding it.

Every optional dependency degrades the same way: what Tainted can check narrows, and the report says which parts it skipped.

Tests

PYTHONPATH=. pytest tests/                                   # engine
PYTHONPATH=.:surfaces/cli     pytest surfaces/cli/tests      # each surface is
PYTHONPATH=.:surfaces/ci      pytest surfaces/ci/tests       # independently deployable
PYTHONPATH=.:surfaces/mcp     pytest surfaces/mcp/tests
PYTHONPATH=.:surfaces/website pytest surfaces/website/tests

Every test is hermetic: the LLM, the HTTP transport, the mutation runner, Semgrep and the agent driver are all fakeable. The suite needs no API key, no network, and no browser.

All four run on every push — see .github/workflows/test.yml, which installs exactly the two lines from Setup above, so a command that works in CI works on your machine.

Shipping it

Each surface is a different kind of artifact: the CLI and MCP server are PyPI packages, the CI surface is a GitHub Action, the website ships as a wheel on the GitHub Release, meant to run on a host with Docker. PUBLISHING.md has one section per surface and the single tag that publishes all four (.github/workflows/release.yml).

License

MIT — see LICENSE. Use it, fork it, sell it; keep the copyright notice with it.

Metadata

Release files for tainted 0.1.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tainted 0.1.3
File Size Uploaded Provenance Actions
tainted-0.1.3.tar.gz 151.7 kB With provenance Details for tainted-0.1.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for tainted 0.1.3
File Interpreter ABI Platform Actions
tainted-0.1.3-py3-none-any.whl Python 3 none any Details for tainted-0.1.3-py3-none-any.whl

Total release size: 284.9 kB

Release files / tainted-0.1.3.tar.gz

Download URL tainted-0.1.3.tar.gz
Size 151.7 kB
Tags Source
SHA-256 checksum
How to use checksums
ff581f9e95c2842ee5678690c99ae34648a5b8a098e22910dadc3424c507341b
BLAKE2b-256 checksum
How to use checksums
d25c9b11bd8087d0855cbef5e06fc19d927b51a34eafc0df6b144f1cc725f4e4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Release files / tainted-0.1.3-py3-none-any.whl

Download URL tainted-0.1.3-py3-none-any.whl
Size 133.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d5831d249a7256b035ca56f6bdefe91ab4fbd4bbbb90f15c6234b05a0f729860
BLAKE2b-256 checksum
How to use checksums
a2438b083671a7bf49220ff237e7d5a5e3d4af9f79d4df45e5d731a5922a5640
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

Security for tainted 0.1.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for tainted-0.1.3.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page