Skip to content

Latest commit

Β 

History

73 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

verify release ghcr license: MIT python 3.12+

TenantTrace

Proves whether tenant A can reach tenant B's data.

A multi-tenant isolation auditor for SaaS applications: it finds broken object level authorization (OWASP API1:2023, CWE-639) across tenants and proves each finding with evidence, so the report can gate a merge instead of starting an argument.

Your test suite runs as one tenant, so the one query that forgot its tenant filter looks fine. TenantTrace seeds two tenants into your application, asks for one while authenticated as the other, and reports what came back.

βœ— [critical/confirmed] Cross-tenant read on GET /api/invoices/{invoice_id}
    GET /api/invoices/018f4c1e-3a9b-7c2d-9e5f-1a2b3c4d5e6f
    200 Β· body contains tt-canary-B-…3f7a91c2

That is not a similarity score or a heuristic. We planted that string in tenant B's invoice ninety milliseconds earlier, and it came back to tenant A.


See it work

Open an example report β†’ Β· the same run against a correctly isolated app

Both are generated by the real tool on every deploy, not screenshots.

git clone https://github.com/halilibrahimd27/tenant-trace
cd tenant-trace
docker compose up -d

That boots two multi-tenant applications β€” one deliberately leaky, one correctly isolated β€” audits both over real HTTP with a real Redis, and serves the reports at http://127.0.0.1:8088. Nothing else to install.

Reports live in a named volume, so the demo runs as a non-root user on every platform. To pull them onto your disk: make reports (or docker compose cp report:/reports ./reports).

Without Docker:

uv sync --extra dev --extra fixtures
uv run tenanttrace demo          # audits both fixtures in-process, writes HTML

How it decides

Findings are facts, not scores. Every record belonging to tenant B is seeded with a unique canary string. If that canary appears in a response served to tenant A, the leak is confirmed β€” there is nothing to interpret.

Aggregates work the same way: we seeded tenant A's rows, so we know the correct count. Anything higher is a confirmed leak.

When the oracle cannot decide β€” a 5xx, a timeout, an unparseable body β€” the verdict is inconclusive. It is never silently a pass.

Every run checks its controls first. Tenant A must be able to read tenant A's own data. If that fails, authentication or seeding is broken and the run is marked INVALID with exit code 3. A run that could not reach anything is not a run that found nothing β€” that distinction is the difference between a security tool and a placebo.

What it catches

proven by
Object reads A fetches B's record by id B's canary in the response
Collection leaks A's own list contains B's rows B's canary in the response
Aggregate leaks counts computed over every tenant arithmetic against seeded rows
Parameter override ?tenant_id=B, X-Tenant-Id: B, body fields canary, after a clean baseline
Cache-key leaks correct query, tenant-less cache key refused cold, served after B warms it
Cross-tenant writes A creates a record inside B reading it back as B
Public endpoints the same data comes back with no credential replaying the leak anonymously

The last row is a different bug wearing the same clothes. If an unauthenticated request gets the record too, tenant scoping is not the control that failed, and adding a WHERE tenant_id = … fixes nothing because there is no caller to scope to. TenantTrace asks before it blames scoping (ADR-0011).

The cache case is the one worth pausing on. The query is correct β€” code review sees a proper WHERE tenant_id = …, and a single-tenant test suite passes β€” but the result is cached under invoice:{id}, so whichever tenant asks first wins. TenantTrace finds it by requesting the object cold (refused), having the owner read it (populating the cache), then repeating the first request. See ADR-0008.

The report

One self-contained HTML file β€” no CDN, no fonts, no outbound requests, so it is safe to attach to a ticket or open on an air-gapped machine. It opens with the answer, not the method:

  • The verdict. 6 confirmed cross-tenant leaks, or No cross-tenant access proven β€” 34 attempts refused across 10 endpoints. A clean run says what it covered, because "no findings" and "nothing was tested" must never read alike.
  • An access graph. Which tenant reached which endpoint, drawn only from proven results β€” a LEAKED verdict is an edge, an ENFORCED one is not. The idea is BloodHound's: on a large API the useful sentence is "four of these hundred endpoints are where the boundary breaks", and a table cannot say it.
  • Every finding with the request that proved it β€” the canary, the response, and a fix aimed at the data-access boundary rather than the route handler.
  • Run integrity last. Positive controls, what the application refused, and anything the oracle could not decide.

--format md writes the same content for a pull request; --format json for a machine. Credentials are stripped and canaries shortened in all three.

Two engines

probe dynamic, language-agnostic Sends real requests. Works against FastAPI, Laravel, Rails, .NET β€” anything speaking HTTP. Findings are confirmed.
static per-language adapter Reads source to find suspicious paths and the leaks HTTP cannot see: raw SQL, cache keys, job payloads. Findings are suspected until the prober confirms them.

Static proposes, dynamic proves. Only confirmed findings fail your build by default β€” that is what keeps the gate tolerable. See ADR-0002.

The static engine parses with the standard library's ast. It never imports or executes the code under analysis (ADR-0005).

Two adapters ship β€” python_sqlalchemy and python_django β€” and adapter = "auto" picks by looking at imports. Writing the second one is what showed that three of the six rules were never about an ORM at all: raw SQL, cache keys and job payloads are patterns in Python, so they moved to static/rules.py where both adapters read them (ADR-0012).

Why not the tools you already have

Adjacent tools exist and this one is not a replacement for any of them.

What it does Why it does not answer this question
Semgrep / CodeQL Pattern-match "query without a tenant filter" Static only, so nothing is ever confirmed β€” and against the repository/service-layer pattern, where the filter lives far from the query, the rule flags every call site. Hundreds of findings, no signal.
Burp Autorize / AuthMatrix Replay your requests with a second identity Proxy-driven: you browse, it replays. The oracle is response similarity, which is noisy in both directions and cannot judge an aggregate at all. It does not run as a merge gate.
Schemathesis / Dredd Property-test an API against its schema Excellent at schema conformance. Has no concept of tenancy.
Your test suite Everything else Runs as one tenant, so the query that forgot its filter returns the right answer.

What is actually different here:

  1. The oracle is exact. We seed the data we later go looking for, so a finding is true or it isn't β€” no similarity scoring, no thresholds.
  2. Static proposes, dynamic proves. Hypotheses only gate CI once a real request confirmed them. That is what makes the false-positive rate low enough to block a merge on.
  3. It runs headless. A merge gate with a baseline file, not an interactive proxy session.
  4. It crosses layers. Cache keys, aggregates, and background-job payloads, not only endpoint responses.

If your application has no OpenAPI document, export a HAR from one click-through and point TenantTrace at that instead.

Wiring it to your app

TenantTrace cannot guess how your application creates a tenant, authenticates one, or creates an owned record. You write that once, in about thirty lines β€” start from seeders/example_seeder.py or the working fixtures/seeder.py:

class MySeeder:
    def __init__(self, client): self.client = client

    def create_tenant(self, label):          # -> {"tenant_id": ..., "access_token": ...}
    def auth_headers(self, tenant):          # -> {"Authorization": f"Bearer {...}"}
    def seed_records(self, tenant, canary):  # -> records carrying the canary
    def cleanup(self, tenant):               # -> remove what you created

Put the canary in a field the API actually returns β€” a title, name, or description. Create at least two records per kind: the harness keeps its control reads and its attack reads on different records (ADR-0008).

Two details decide whether the rest of the run works, and neither raises anything when wrong:

  • tenant_id is the tenant as it appears in a URL path. The prober substitutes it into tenant path parameters, so for /api/v1/accounts/{account_id}/… it is the account id and for /admin/realms/{realm}/… the realm name. Wrong value, and the canonical cross-tenant test never runs.
  • kind must equal the endpoint's resource segment, lowercase and singular: /api/invoices/{id} wants kind="invoice". Wrong value, and every endpoint quietly falls back to trying a few ids blindly. The run says so when no kind matches anything.

A nested record declares the parents that lead to it β€” {"kind": "row", "id": "1", "path": {"table_id": "38"}} β€” because a row cannot be addressed from a row id alone. [tenancy] path_literals pins a slot that names a type rather than an object, as Squidex's {schema} does.

SeederClient is optional and removes the call/check/decode dance every real seeder wrote by hand; its value is the failure messages, which name the request, the expected status, and what the application actually said.

Then point at it:

[target]
base_url      = "http://127.0.0.1:8000"
allowed_hosts = ["127.0.0.1", "localhost"]
spec          = "openapi"        # or "har" / "postman" / "routes"
spec_path     = "http://127.0.0.1:8000/openapi.json"

[seeder]
adapter = "seeders.my_app:MySeeder"

[tenancy]
column                 = "tenant_id"
cross_tenant_allowlist = ["/api/admin/*"]   # endpoints that cross tenants on purpose
tenanttrace validate-config -c tenanttrace.toml   # says exactly what it will do
tenanttrace probe -c tenanttrace.toml --dry-run   # lists attempts, sends nothing
tenanttrace probe -c tenanttrace.toml

tenanttrace.example.toml documents every key, and a test asserts the loader accepts it β€” so it cannot drift.

From your own test suite

The prober takes an injected transport, so it can drive an ASGI application in-process with no server, no port, and no container:

from tenanttrace.probe.asgi import SyncASGITransport
from tenanttrace.probe.runner import ProbeOptions, run_probe

def test_tenants_are_isolated():
    with SyncASGITransport(my_app) as transport:
        report = run_probe(config, ProbeOptions(transport=transport)).report
    assert report.status is RunStatus.VALID     # controls passed β€” the run is real
    assert report.confirmed == ()

In CI

- uses: halilibrahimd27/tenant-trace@v0
  with:
    config: tenanttrace.toml
    fail-on: high
    baseline: .tenanttrace-baseline.json

Accepted findings live in the baseline and stay quiet; new ones fail the check. Fingerprints survive re-seeding, endpoint reordering, parameter renames, and line-number churn β€” they are built from the endpoint or the source symbol, never a line number (ADR-0007). The baseline holds fingerprints and titles only: never a canary, a token, or a response body.

Exit codes: 0 clean Β· 1 findings at or above fail-on Β· 2 usage or config error Β· 3 run INVALID, positive controls failed.

The second run

Every other view answers "what did this run find?". A team running it weekly needs the other question, and an application that still holds and one the harness no longer reaches both report no findings.

tenanttrace diff .tenanttrace/myapp/runs/<earlier> .tenanttrace/myapp/runs/<later> \
  --fail-on-regression

It reports three regressions β€” an endpoint no longer tested, an endpoint whose every attempt is now inconclusive, and fewer refusals than before β€” and can fail a build on coverage alone, with no new finding. Only ENFORCED counts as coverage: an endpoint that was visited but never decided was not tested. It refuses to compare against an INVALID run, because comparing with a run that never happened is how a regression gets explained away.

While you write

git clone https://github.com/halilibrahimd27/tenant-trace
claude plugin marketplace add ./tenant-trace
claude plugin install tenant-trace@tenant-trace

A Claude Code plugin with two surfaces. A hook runs the static engine over each Python file you edit and reports queries with no visible tenant predicate β€” it never blocks, never probes, and stays quiet when it has nothing to say. A skill carries how to run a real audit, why INVALID is not a clean result, and what "refused" counts.

What it will not find

Being specific about this is part of the tool being trustworthy.

  • Anything it cannot seed. The oracle works because TenantTrace plants the data it later goes looking for. It cannot audit a system you are not allowed to write to.
  • Leaks with no HTTP surface. A report generator writing the wrong tenant's rows to a file nobody fetches is invisible to probing. The static engine can flag the code path; it cannot prove the leak.
  • Routes it never hears about. Coverage comes from an OpenAPI document, a HAR capture, a Postman collection, or a hand-written route list. Undocumented endpoints go untested, and the report says how many endpoints it knew about so a thin inventory cannot pass for a clean result.
  • Sums. The aggregate oracle judges *_count fields against seeded row counts. It does not judge *_total, because that is usually money and comparing it to a row count would report a critical against correct code.
  • Authorization beyond tenancy. Whether a viewer can act like an admin within one tenant is a different question, and this tool does not ask it.
  • Non-Python codebases, statically. The prober is language-agnostic; the static engine ships Python adapters only β€” SQLAlchemy and Django. Point it at a Rails or Laravel tree and it says it does not recognise it, rather than reporting nothing found.

Safety

The prober sends adversarial requests and, with --allow-mutation, writes data.

  • Read-only by default. Mutating attacks require --allow-mutation on the command line and allow_mutation = true in config. Neither alone is enough.
  • Host allowlist. The target host must appear in allowed_hosts.
  • Non-loopback targets additionally require --i-have-authorization. That flag is a statement you are making, not a permission this tool grants you.
  • Redirects are not followed β€” a redirect could move a request to a host outside the allowlist.
  • Rate limited to max_rps, shared across both tenant sessions.
  • Credentials are redacted where the record is created, not at render time, so a token has no path to an artifact. A test asserts no JWT reaches exchanges.jsonl.
  • Every request and response is captured to .tenanttrace/, which is gitignored because it contains real findings.

Mutating attacks clean up after themselves, and say so in the finding when they could not.

See SECURITY.md and THREAT_MODEL.md.

Development

make install       # uv sync --extra dev --extra fixtures
make verify        # ruff Β· black Β· mypy --strict Β· pytest β‰₯88% Β· recall β‰₯90%
make demo          # audit both fixtures, write reports
make fixtures-up   # boot the fixtures in Docker (only needed for the HTTP demo)

make verify is the gate and CI runs the same command. It is hermetic β€” no Docker, no Redis, no network β€” because the fixtures are driven in-process over ASGI (ADR-0004).

The gate includes a precision/recall score against fixtures/labels.yaml, the answer key describing every hole in the fixture applications. Recall below 90%, or any false positive on the correctly-isolated app, fails the build. That is what turns "I think it works" into a number.

CONTRIBUTING.md has the house rules, CLAUDE.md has the non-negotiables, and every significant decision is recorded in docs/adr/.

License

MIT β€” see LICENSE.

About

πŸ›‘οΈ Multi-tenant isolation auditor β€” proves whether tenant A can reach tenant B's data. Seeds two tenants, attacks one as the other, and reports confirmed BOLA/IDOR leaks with canary-backed evidence. CI merge gate, near-zero false positives. One command: docker compose up -d

Topics

Resources

Contributing

Security policy

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages