Authorization and scope. ble-doctor is for your own devices, CTF targets, and systems you have written permission to test. It is a defensive analysis tool.
probe-writestransmits to the target and is opt-in per characteristic. Do not point it at hardware you do not own or have explicit authorization to assess.
Hand it a BLE capture or a device address. It scores the security posture 0-100 and tells you what is weak: pairing method, unauthenticated writes, DFU exposure, address rotation.
ble-doctor analyze capture.pcap # offline: score a btmon/Ubertooth capture
ble-doctor scan --target AA:BB:CC:DD:EE:FF # live read-only GATT enumeration + score
ble-doctor probe-writes AA:BB:CC:DD:EE:FF --char <uuid> # opt-in: test one characteristic for open writes
Every subcommand takes --json for a stable machine-readable envelope. No telemetry; nothing
leaves your machine.
Get started
1. Install
uvx ble-doctor analyze capture.pcap # Python, via uv; nothing to install
pipx install ble-doctor
npx ble-doctor analyze capture.pcap # Node launcher: runs the same version through uvx or pipx
pip install ble-doctor
From a clone: pip install -e .. Requirements:
| Need | For |
|---|---|
| Python 3.10+ | everything |
tshark (Wireshark) on PATH |
analyze (capture parsing, SMP pairing dissection) |
bleak (installed with the package) and a BLE adapter |
scan, probe-writes |
bluesploit (optional) |
an INFO cross-check inventory in scan; never moves the score |
2. Score a capture
ble-doctor analyze capture.pcap
BLE Doctor — score: 85/100
[FAIL] mac_rotation (medium): ...
[PASS] pairing_method (info): ...
(Illustrative; real detail strings depend on the capture.) If the capture contains no SMP pairing exchange the pairing check is skipped and the reason is printed, not scored.
3. Enumerate a live device
ble-doctor scan --target AA:BB:CC:DD:EE:FF # or set BLE_DOCTOR_TARGET
--target takes an address or advertised name; with none given and exactly one device in range, it
uses that one. The connection is read-only.
4. Probe for unauthenticated writes (opt-in)
ble-doctor probe-writes AA:BB:CC:DD:EE:FF --char 0000ffe1-0000-1000-8000-00805f9b34fb
Connects without bonding and attempts one harmless write per named characteristic, then
classifies what the device answered. Nothing is written unless you name characteristics
(--char, --service) or pass --allow-all. Firmware-update characteristics are refused unless
you also pass --allow-dangerous, which can brick a target.
5. Teach your coding agent
ble-doctor install # Claude Code skill + Cursor rule + AGENTS.md block in this repo
ble-doctor install --print-only # show what would be written
CLI reference
ble-doctor scan [--target ADDR_OR_NAME] [--json]
Live checks: rig state, GATT enumeration, write surface, DFU, bluesploit inventory.
--target defaults to $BLE_DOCTOR_TARGET.
ble-doctor analyze <capture_file> [--json]
Offline checks on a btmon/btsnoop/Ubertooth capture: advertising-address rotation and SMP pairing.
ble-doctor probe-writes <address> [flags]
| Flag | Meaning |
|---|---|
--char UUID |
characteristic to probe; repeatable |
--service UUID |
service whose characteristics may be probed; repeatable |
--allow-all |
probe every writable characteristic (firmware-update ones still refused) |
--allow-dangerous |
also probe firmware-update/erase characteristics. Real side effects. |
--payload HEX |
probe payload in hex (default is a harmless one) |
--json |
machine-readable output |
ble-doctor rules list [--json] / ble-doctor rules explain <id> / ble-doctor why <id> [--report FILE]
Browse the per-check rule catalog: list shows every check id, explain <id> prints that check's
what/why/fix/verify doc, and why <id> [--report FILE] prints that doc and, with --report, the
finding(s) for that id from a saved scan --json / analyze --json report.
ble-doctor mcp
Serves analyze and scan to an MCP client over stdio (pip install 'ble-doctor[mcp]' first; without the extra it prints how to install it and exits 2). Each tool returns the same JSON as the matching --json command. probe-writes is not exposed: it writes to a device, so an agent cannot trigger it.
ble-doctor fix <rule-id> --report FILE [--agent claude|codex|cursor] [--skip-approvals]
Prints a fix prompt for one failed finding from a saved scan --json / analyze --json report: the rule text, the finding as untrusted data inside a fence (control and bidi characters stripped, backticks replaced so nothing can close the fence), the task, and how to verify. With --agent it launches that coding agent with the prompt. The agent keeps its own approval prompts unless you pass --skip-approvals (or set BLE_DOCTOR_HANDOFF_SKIP_APPROVALS=1), because the prompt contains text from a device or capture. The per-agent flags behind --skip-approvals are copied from the sibling tool pcap-doctor and have not been verified against every version of each CLI; check yours. Inside a coding agent (CLAUDECODE, CODEX_THREAD_ID, CODEX_SANDBOX, CURSOR_SANDBOX, or BLE_DOCTOR_AGENT=1) nothing is launched.
Gating a build: --baseline and --fail-on
scan and analyze accept --baseline FILE (a report saved with --json) and --fail-on info|low|medium|high. With --baseline, only failed findings that are new since that report count; a finding keeps its identity across runs (the same fingerprint as the SARIF output: the check, its severity and its text, with only the frame counts and durations masked, so the same weakness on a longer capture is not new while a different UUID or address is). The run exits 3 when a counted finding is at or above --fail-on; exit 1 stays an error (for example an unreadable baseline, which is never treated as a pass). The gate sees only the checks that ran: a scan whose GATT enumeration did not run has no GATT findings to gate on, and says so on stderr. --json output gains a baseline object (new_findings, known_failed) only when --baseline is given, so the default shape is unchanged.
GitHub Action
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- uses: Vaibhav91one/ble-doctor@v0.2.0
with:
captures: "captures/**/*.pcap"
fail-on: high
On a pull request the action analyzes each matching capture, compares it with the same file at the base commit, reports and gates only the findings that are new (the exit-3 gate of --baseline/--fail-on), posts one summary comment, and uploads SARIF to code scanning. fail-on: none reports without gating. Needs pull-requests: write for the comment and security-events: write for the upload. The v0.2.0 ref in the example is the release that ships this action: it exists once that release is cut, so pin a branch or a commit until then. Notes: the SARIF upload contains every failed finding of each capture, not only the new ones (code scanning de-duplicates by fingerprint); a capture that is new in the pull request, or renamed, has no base copy, so all its failed findings count as new; on a push event there is no base commit, so every failed finding gates; a captures pattern that matches nothing passes with a warning. The gate logic is tested locally; the comment and SARIF-upload steps are exercised on a hosted runner by the action-selftest workflow.
ble-doctor ci install
ble-doctor ci install writes .github/workflows/ble-doctor.yml, a pull-request workflow that runs the GitHub Action above on the captures in your repository. Options: --captures (space-separated globs, default **/*.pcap **/*.pcapng **/*.btsnoop), --fail-on none|info|low|medium|high (default high), --ref (the action ref to pin; default this version's tag v<version>, which exists once that release is cut), --print-only (print, write nothing), --force (overwrite a workflow that differs; without it a differing file is kept and the command exits 1) and --dir (repository root). The globs and the ref are checked before they are written into the YAML, and a symlinked .github or workflow file is refused.
SARIF output
ble-doctor scan --sarif PATH and ble-doctor analyze <capture_file> --sarif PATH also write the failed findings as a SARIF 2.1.0 file for code-scanning tools, with a stable partialFingerprints entry per finding. stdout is unchanged (--json still prints exactly one report). A live scan has no file location, so its results carry a logical BLE device location; GitHub code-scanning upload has not been verified yet.
ble-doctor install [--agent claude|cursor|codex] [--print-only] [--force] [--dir PATH]
Writes .claude/skills/ble-doctor/SKILL.md, .cursor/rules/ble-doctor.mdc and a managed block in
AGENTS.md. --agent is repeatable (default: all). Existing differing skill/rule files are kept
unless --force.
Exit codes: 0 ok, 1 the operation failed (missing tshark/bleak, unreadable capture), 2 bad arguments.
JSON output
analyze --json:
{
"pairing_skipped": "no SMP pairing exchange in capture",
"score": 100,
"findings": [
{"id": "mac_rotation", "severity": "info", "passed": true, "detail": "..."}
]
}
scan --json has the same score and findings, plus rig_state, gatt_checks_skipped
(null or a reason), gatt_enumeration (depth, service_count, writable_char_count),
bluesploit_skipped, bluesploit_recon, and, when enumeration succeeded, the raw gatt_info.
probe-writes --json: {"results": [...], "writable_chars": [...], "findings": {"score", "findings": [...]}}.
A non-null skip field means those checks did not run. It is never a pass.
The score
score = 100 x product(1 - weight/100) over failed checks, rounded half-up. Weights: high 30,
medium 15, low 5, info 0. Monotonic (more or worse findings never raise it), with diminishing
returns, and one HIGH leaves 70 rather than zero.
Check catalog
| ID | Severity | What it measures |
|---|---|---|
pairing_method |
high / medium / low / info | Pairing association model inferred from the SMP exchange (Core Spec Vol 3 Part H Table 2.8). Just Works is weakest; undeterminable or reserved IO capability is medium. |
unauthenticated_writes |
high / info | Characteristics writable without bonding. scan uses a heuristic; probe-writes measures it. |
dfu_entry_point |
high / info | HIGH only when the DFU control point is writable without bonding or encryption. A DFU service that is bond-protected, or merely present, is INFO. |
dfu_signing_enforced |
high / medium / info | Reads a recorded signing probe. With no probe it is INCONCLUSIVE at INFO (0 points): a queued probe, not a pass. |
dfu_surface_absent |
info | States whether "no DFU surface" is backed by a complete enumeration or is unknown. |
mac_rotation |
medium / info | Whether the advertising address rotates, duration-aware; too short a capture says so instead of judging. |
bluesploit_recon |
info | Read-only bluesploit module inventory as a manual-reproduction pointer. Never moves the score. |
Where a row lists several severities, the check picks one from the evidence; see detail.
Known limits
- Validated only against an nRF Connect test double and synthetic fixtures, not a genuine third-party target. Treat results as unproven on real hardware until #40 lands.
requires_bondingfromscanis a heuristic (vendor characteristics assumed bond-protected); onlyprobe-writesmeasures it.- DFU signing is not probed live yet (#41): it stays INCONCLUSIVE.
scancannot observe the pairing method (it is an SMP fact); useanalyzeon a capture.- bleak + BlueZ can wedge discovery with
org.bluez.Error.InProgress; the tool retries and then tells you tosudo systemctl restart bluetooth. - Radio-layer only. Companion-app and cloud-side security are out of scope.
- No SARIF, MCP server, or CI action yet: see the open issues.
Testing
pip install -e '.[dev]' && python -m pytest test/ -q
Capture tests need tshark and skip without it; CI installs it and fails if they skip.
See AGENTS.md for the contributor contract.
License
Metadata
Release files for ble-doctor 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ble_doctor-0.2.0.tar.gz | 79.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ble_doctor-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 168.6 kB
Release files / ble_doctor-0.2.0.tar.gz
| Download URL | ble_doctor-0.2.0.tar.gz |
|---|---|
| Size | 79.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f49a06fa7beb880fcc549c098a6ca675d3296693204fd5d66ddcc6f19f3fdb6d
|
|
BLAKE2b-256 checksum How to use checksums |
34bb0f73583d8b569e213895789bb779bdfa21d2bec53409c9a996a15d8ce443
|
| 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 Oct 6, 2026.
Transparency logRelease files / ble_doctor-0.2.0-py3-none-any.whl
| Download URL | ble_doctor-0.2.0-py3-none-any.whl |
|---|---|
| Size | 89.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
19305efb28422c39207a8681d436928e180c9e33dfaab18a8c4a9561c16c1d7c
|
|
BLAKE2b-256 checksum How to use checksums |
b3dcf3229fcf5f699abde423a3d418fb47d7a873b9445405436a7bbc6d548136
|
| 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 Oct 6, 2026.
Transparency log