Multi-tenant isolation auditor

Your tests run as
one tenant.
That's the bug.

Every test you have passes as a single customer, so the one query that forgot its tenant filter looks fine. TenantTrace seeds two tenants, asks for one while signed in as the other, and tells you what came back.

confirmed cross-tenant read TT-0007

signed in as tenant A

GET /api/invoices/018f9c…b41d

response200 OK

{

"id": "018f9c…b41d",

"title": "tt-canary-B-7f3a91 invoice 2",

"amount": 102

}

tenant B's canary returned to tenant A — proof, not a guess

The leak nobody's test suite finds

It only takes one

A tenant id enters through middleware and has to stay attached through every service call, ORM filter, cache key and job payload. Drop it in one place and one customer reads another customer's invoices.

Static analysis alone cries wolf

"Query without a tenant filter" flags every repository call in a well-built app, because the filter lives at the boundary, not the call site. Hundreds of findings, no signal, nobody reads the report twice.

So we confirm instead of guessing

Static analysis proposes; the prober proves. Only findings reproduced against a running app are reported as confirmed — and only those fail your build.

How a run works

Order matters here — step 2 is what stops a broken harness from reporting a clean bill of health.

  1. 01

    Seed two tenants

    Your seeder adapter — about thirty lines, written once per app — creates tenant A and tenant B. Every B record carries a unique canary string in a text field.

  2. 02

    Check the controls

    A reads A's own data. If that fails, authentication is broken and the run is marked invalid. A run that can't reach anything is not a run that found nothing.

  3. 03

    Cross the line

    Now A goes after B: object ids, list endpoints, aggregate counts, tenant parameters in the query string, and — if you allow writes — moving a record into someone else's account.

  4. 04

    Read the verdict

    B's canary in A's response is a confirmed leak. A count higher than A's seeded total is a confirmed leak. Anything the oracle can't decide is reported as inconclusive, never as a pass.

What it looks for

CheckQuestion it asksEngineClassification
Object access Can A fetch B's record by id? probe CWE-639 · API1:2023
Listing Does A's collection endpoint include B's rows? probe CWE-200 · API1:2023
Aggregates Does a row count reach past A's own data? probe CWE-200 · API1:2023
Parameter override Does the app trust a client-supplied tenant id? probe CWE-639 · API1:2023
Mass assignment Can A create or move a record into B? probe CWE-915 · API3:2023
Cache keys Is a cached value keyed without the tenant? probe static CWE-524 · API1:2023
Scope escapes Raw SQL or a bypass flag stepping around the global filter? static CWE-284
Job payloads Does a background job lose the tenant on its way to the queue? static CWE-639

probe findings are reproduced against a running app and reported as confirmed. static findings are suspicions — they're marked as such, and they don't fail your build on their own.

Quickstart

See it work — one command, nothing to install

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

# two apps audited over real HTTP; reports at :8088

Point it at your own application

# not on PyPI yet — install from the repository
pip install git+https://github.com/halilibrahimd27/tenant-trace

# scaffolds tenanttrace.toml and a seeder stub
tenanttrace init

# says exactly what it will do, before it does anything
tenanttrace validate-config
tenanttrace probe --dry-run

# two tenants, one question
tenanttrace probe
tenanttrace report --format md

Run it on every pull request

# .github/workflows/tenant-isolation.yml
- uses: halilibrahimd27/tenant-trace@v0
  with:
    config: tenanttrace.toml
    fail-on: high
    baseline: .tenanttrace-baseline.json

Known findings you've accepted go in the baseline and stay quiet. New ones fail the check. Fingerprints survive re-seeding, endpoint reordering and line-number churn — here's exactly what goes into the hash, and what deliberately doesn't.

What that actually prints

$ docker compose up -d

── auditing vulnerable at http://vulnerable-app:8000 ──
run VALID — 10/11 endpoints probed, 30 cross-tenant attempts

 TT-0001  critical  confirmed  param_override      GET /api/customers
 TT-0002  critical  confirmed  listing_leak        GET /api/documents
 TT-0003  critical  confirmed  cross_tenant_read   GET /api/invoices/{invoice_id}
 TT-0004  critical  confirmed  cross_tenant_write  POST /api/invoices
 TT-0005  high      confirmed  cache_key_leak      GET /api/documents/{document_id}
 TT-0006  high      confirmed  aggregate_leak      GET /api/stats

6 new confirmed finding(s) at or above high (worst: critical)     exit 1

── auditing safe at http://safe-app:8000 ──
run VALID — 10/12 endpoints probed, 34 cross-tenant attempts
Findings: none
no new confirmed findings at or above high                        exit 0

Same command, same routes, opposite results — and the clean run reports 34 refused attempts, because "no findings" only means something when you can see what was tried.

Both of these are on this site as real pages — open either one.

What it won't find

A security tool that only lists its strengths is telling you half of something.

  • Anything it can't seed. The oracle works because TenantTrace plants the data it later goes looking for. It can't audit a system you're not allowed to write to.
  • Leaks with no HTTP surface. If a report generator writes the wrong tenant's rows to a file nobody fetches, probing won't see it. The static engine flags the code path; it can't prove the leak.
  • Routes it never hears about. Coverage comes from an OpenAPI document, a HAR capture, a Postman collection, or a route list. Undocumented endpoints stay untested — and the report says how many endpoints it knew about, so a thin inventory can't pass for a clean result.
  • Sums. The aggregate oracle checks *_count fields against the rows it seeded. It deliberately doesn't judge *_total — that's 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 doesn't ask it.
  • Non-Python codebases, statically. The prober speaks only HTTP and works against anything. The static engine currently ships one adapter: Python and SQLAlchemy.