Tblue is an open-source web security scanner for people who own websites. You point it at your site, it tells you what looks wrong, and it runs entirely on your machine. No account, no API key, no findings uploaded anywhere.
It is also a tool I got wrong in public, had corrected by a stranger who read the source properly, and then rebuilt. That second part is the more useful thing to write about, so I will start there.
The word "passive" was doing work it hadn't earned
Tblue is meant to be blue-team only: read HTTP responses, headers, cookies, JavaScript, page content, and report. Nothing modified, nothing brute-forced.
A security reviewer went through the source and pointed out that the default scan was submitting logins, sending password-reset requests, firing XXE payloads referencing /etc/passwd, running port scans, and issuing Redis and Memcached commands. Two modules were even named xxe_passive and log_injection_passive while sending payloads.
They were right. I had classified modules by what I assumed they did.
So I measured instead. I ran every scanner against an instrumented HTTP server that recorded each request, and moved anything that issued a POST/PUT/PATCH/DELETE, or a GET carrying a traversal, XXE, CRLF or injection payload.
A depth-1 scan, before and after:
| requests | POST/PATCH | attack payloads | |
|---|---|---|---|
| before | 1,664 | 205 | 104 |
| after | 1,219 | 0 | 0 |
That produced three tiers:
| Tier | Flag | Modules | What it sends |
|---|---|---|---|
| Passive | (default) | 582 | GET/HEAD only |
| Probe | --probe |
12 | Crafted but side-effect-free |
| Intrusive | --active |
20 | Submissions, payloads, port scans |
The part that matters: the tiering is enforced
A classification you maintain by hand rots the first time someone adds a module. So it is a test:
for key, klass, _ in cli._SCANNER_REGISTRY:
sent.clear()
klass(session, allowed_host="127.0.0.1").scan(target)
bad = [r for r in sent
if r[0] in _MUTATING or _PAYLOAD.search(r[1] + " " + r[2])]
if bad:
offenders[key] = bad[0]
self.assertEqual(offenders, {})
Every one of the 582 default scanners runs against a live instrumented server in CI. If a contributor adds a module that sends a POST and leaves it in the default tier, the build fails and names the module. The guarantee is checked on every push rather than promised in a README.
Credentials and blast radius
The same review found that authenticated scans leaked. Passing --bearer or --cookie attached those values to a shared requests.Session, and enrichment scanners used that same session to reach crt.sh, OSV and NVD. Those services received the caller's credentials.
The fix is a scoped session: anything off-target goes through a session with no auth, no cookies, no user-supplied headers.
While testing that I found a second path the reviewer had not seen. Choosing a session per request covers requests you issue yourself. Redirects are different — requests follows them inside a single send(), using whichever session began the call. So a target answering 302 with an off-host Location still handed that host your custom headers and cookie jar.
--bearer and --auth were already safe, because requests' own rebuild_auth drops Authorization when the netloc changes. It leaves arbitrary headers alone, and a cookie set without an explicit domain matches any host:
class ScopedSession(Session):
def rebuild_auth(self, prepared_request, response):
super().rebuild_auth(prepared_request, response)
if _host_in_scope(prepared_request.url, self.allowed_host):
return
for name in self.scoped_headers:
prepared_request.headers.pop(name, None)
prepared_request.headers.pop("Cookie", None)
If you are building anything that carries credentials through requests, this is worth knowing. rebuild_auth is the hook, and it only handles Authorization for you.
What it actually checks
582 modules run in parallel on every scan, across TLS, headers, cookies, auth, OAuth, CSRF, injection sinks, XSS sinks, SSRF, secrets in JS bundles, API security, supply chain, cloud exposure, DNS and email, browser APIs, privacy, and compliance mappings for PCI-DSS, HIPAA, SOC 2, ISO 27001 and NIST CSF.
$ tblue -u https://example.com
╭──────────────────────────────────────────────────────────╮
│ B Security Score 72/100 ██████████████░░░░░░ │
├──────────────────────────────────────────────────────────┤
│ ● 🟠 High 2 −20 pts │
│ ● 🟡 Medium 1 −5 pts │
│ ● 🔵 Low 3 −3 pts │
├──────────────────────────────────────────────────────────┤
│ 1. [FAIL] Security headers │
│ 2. [FAIL] CSP — missing │
│ 3. [FAIL] Clickjacking — no framing protection │
╰──────────────────────────────────────────────────────────╯
Failing a PR on a missing header
Someone asked for this and it turned out to be the most useful thing I added.
Tblue had --fail-below, which gates on the aggregate score. That is not enough. A site with Content-Security-Policy entirely missing still scores in the 80s once everything else passes:
--fail-below 80 -> exit 0 (score 84, no CSP at all)
--fail-on high -> exit 1
A score threshold cannot express "this specific misconfiguration must never merge." So there is now --fail-on critical|high|medium|low, gating on the findings themselves. The two gates are independent, and either one failing fails the build.
In GitHub Actions:
- uses: taylannuhogluofficial-png/Tblue@v2
with:
url: https://yoursite.com
fail-on: high
Add sarif: true and pass the file to github/codeql-action/upload-sarif, and findings appear as annotations on the PR diff.
Output formats
Terminal and HTML for humans. JSON for dashboards. SARIF for the GitHub Security tab. For SOC ingestion: Sigma rules, Splunk SPL, Microsoft Sentinel KQL, and CEF/LEEF/Elastic. Findings carry CWE identifiers and MITRE ATT&CK technique mappings.
There is also an MCP server, so you can hand the scanners to an AI client:
pip install "tblue[mcp]"
tblue-mcp
What leaves your machine
Findings are never uploaded. Some scanners look your target up in public intelligence sources — certificate transparency via crt.sh, vulnerability data from OSV and NVD — which discloses the domain being checked. Credentials go only to the target host and its subdomains. --skip those modules for a fully offline scan. AI analysis is opt-in and transmits nothing unless you pass --ai.
One caveat worth stating: if HTTP_PROXY is set, requests go through that proxy and it sees them. That is standard requests behaviour, and it is in the README.
Try it
pip install tblue
tblue -u https://yoursite.com
Python 3.10 through 3.13, MIT licensed. 6,741 tests, green in CI across all four versions.
Only scan sites you own.
Top comments (0)