certdoctor is a GUI/CLI tool for QA engineers, helpdesk and
DevOps - for anyone who has read PKIX path building failed or
unable to get local issuer certificate on a corporate laptop, behind a proxy, or inside a
container that cannot do what the host does without trouble. Point it at
the URL that fails. It walks the whole path in one run - DNS, proxy and PAC script, TCP, TLS - and
then does the part no other tool does: it asks each trust store on the machine, one by one,
whether it trusts what the server presented, and prints the command that would fix each store that
does not, with the real paths from your machine in it. curl -v and openssl s_client answer for
one program. certdoctor answers for the Windows store or the Linux CA bundle, every Java installation,
every git, every curl, every node, every Python and the pip of each it finds - and tells you
whether the fault is on this machine at all.
⭐ If it ended a "works on my machine" argument, leave a star. That is how the next person
staring at PKIX path building failed finds out it exists.
What it can do
- Say whose problem it is - this machine, or the server. A server that does not send its intermediate certificate is named as the server's fault, with the evidence to paste into a ticket.
- One row per trust store, not per runtime - the Windows store as your account sees it and as
services see it, the Linux CA bundle, every JDK and JRE separately, every
git,curl,node, Python andpipit finds. On the machine it was built on, one run in November 2026 read seventeen stores, eight of them Java runtimes. - Print the command that fixes each store -
certutil,keytoolwith the realcacertspath of that JDK, an append to git's own bundle - and say when it needs administrator rights. It prints them. It never runs them. - Notice TLS inspection without accusing anyone - who signed the certificate, how many stores on this machine know that authority, and the product's name when it is one certdoctor recognises.
- Read your proxy settings the way your programs do - the environment variables, the Windows Internet Settings that Edge, Chrome and .NET follow, and the WinHTTP settings that services follow, side by side. It runs your PAC script in a sandbox to see where your URL really goes, and asks the proxy whether it wants credentials.
- Look at DNS twice - every configured resolver asked directly, and the system resolver, with the difference classified: split horizon, a leak past a VPN, a name that resolves nowhere.
- Try every address - a TCP connection to each address the name resolves to, and the full certificate chain from each one, with fingerprints and validity dates.
- Write a report for the ticket - terminal, Markdown, one self-contained HTML page, or JSON with a
schema for CI.
--redactmasks host names, paths and user names. Credentials are masked always. - Change nothing and call nobody - the only file it writes is the report, at the path you name, and the window keeps one setting of its own. It connects only where you pointed it: no telemetry, no update check.
- Ship the command line as one static binary - nothing to install next to it, and it runs as it is inside Alpine and Ubuntu images.
- Show the same report in a window -
certdoctor-gui, for Windows, Linux and macOS: type the address, press Diagnose, read the report and save it as HTML, Markdown or JSON. The window runs the command line next to it, so it knows nothing the command line does not.
A real run, recorded from the binary in a Linux container and paged through less - the verdict
first, then a search for L5, the trust matrix. The few seconds the run takes are cut. BadSSL's
public test root was added to the container's system store - the way a company root usually arrives -
and the two Java runtimes in it were never told.
badssl.com keeps deliberately broken HTTPS sites for exactly this. Three of them, with the first sentence certdoctor prints at the top of each report:
certdoctor https://incomplete-chain.badssl.com/
The server presented 1 certificate(s) and did not send the issuer of the first one, so only a client that already holds it can build a path, and none of the 10 store(s) assessed here built one - this is a fact about the server rather than about this machine.
certdoctor https://untrusted-root.badssl.com/
CN=BadSSL Untrusted Root Certificate Authority,O=BadSSL,L=San Francisco,ST=California,C=US signed this certificate, and none of the 11 store(s) assessed here holds an anchor for it.
certdoctor https://expired.badssl.com/
16 of 17 store(s) do not trust the presented chain, and 1 could not be assessed - a certificate in the path is past its validity.
The second one is where the trust matrix earns its place. Every store it could read refuses, and every store that can be given the root carries the command that would add it there, with this machine's paths in it. curl built on Schannel judges by this account's Windows store, so its rows point at that store's command rather than carrying one of their own. An excerpt from a Windows machine, as printed:
system-store LocalMachine does NOT trust the presented chain (90 anchors) detected
needs elevated rights: the LocalMachine store is read by every account and every service on
this machine, so changing it needs administrator rights
run: certutil -addstore Root "<certificate.pem>"
replace first: <certificate.pem>
note: programs that carry a store of their own - git and java among them - do not read this
one, so their rows need their own commands
java C:\Program Files\OpenJDK\jdk-21.0.2 does NOT trust the presented chain (106
anchors) detected
needs elevated rights: this store is outside this account's own files, so it belongs to
every account on the machine
run: "C:\Program Files\OpenJDK\jdk-21.0.2\bin\keytool" -importcert -trustcacerts -keystore "C:\Program Files\OpenJDK\jdk-21.0.2\lib\security\cacerts" -storepass changeit -alias "<alias>" -file "<certificate.pem>"
replace first: <alias>, <certificate.pem>
note: installing a newer build of this runtime brings its own store and does not carry this
certificate across, and the line is for the Command Prompt (cmd.exe) - PowerShell runs
it only with & and a space put before it
curl C:\Windows\system32\curl.exe does NOT trust the presented chain (90 anchors) - it judges
by system-store CurrentUser, which refuses it detected
The report is laid out 100 columns wide. Sentences wrap at their spaces. A command, a fingerprint, a certificate's name and a value read off the machine are never broken - a command broken over two lines would be pasted as two, so a line that runs past the edge is one meant to be copied whole.
Do not run those for badssl.com. That root is untrusted on purpose, and trusting it would make every store on your machine accept a certificate nobody vouches for. The commands are there for the day the refused root is your own company's.
Download a release. Take the archive for your system from the releases page and unpack it. There is nothing to install.
| Your system | The archive | What you run |
|---|---|---|
| Windows on x64 | certdoctor_<version>_windows_amd64.zip |
certdoctor.exe, signed |
| Windows on Arm | certdoctor_<version>_windows_arm64.zip |
certdoctor.exe, signed |
| Linux on x64 | certdoctor_<version>_linux_amd64.tar.gz |
./certdoctor, one static file |
| Linux on Arm | certdoctor_<version>_linux_arm64.tar.gz |
./certdoctor, one static file |
| macOS on Apple silicon | certdoctor_<version>_macos_arm64.tar.gz |
./certdoctor, a link into certdoctor.app, signed and notarised |
The window, certdoctor-gui, has archives of its own, for three systems. Each carries the
command line as well, because the window runs it:
| Your system | The archive | What you run |
|---|---|---|
| Windows on x64 | certdoctor-gui_<version>_windows_amd64.zip |
certdoctor-gui.exe, signed |
| Linux on x64 | certdoctor-gui_<version>_linux_amd64.tar.gz |
./certdoctor-gui |
| macOS on Apple silicon | certdoctor-gui_<version>_macos_arm64.tar.gz |
certdoctor-gui.app, signed and notarised |
Every archive also carries LICENSE, THIRD-PARTY-NOTICES.md and this README, and the window's
archives THIRD-PARTY-NOTICES-GUI.md too. There is no build for Intel Macs.
Keep the files of the window's archive together. The window starts the certdoctor that sits
next to it. On Windows, the opengl folder holds a software renderer: on a machine whose graphics
driver offers no OpenGL 2.1 - a virtual machine without 3D acceleration, or a Remote Desktop
session - the window starts again with it, by itself.
On macOS, keep each bundle and the links next to it together. The signature and Apple's notarisation ticket belong to the bundle, so a program copied out of it on its own is not the program Apple checked.
On Linux, the command line needs no C library, so the same file runs in Alpine, in Ubuntu and in
an image with nothing else in it. certdoctor --license prints the notices that travel with it. The
window is different: it needs a desktop session with OpenGL, and loads libGL.so.1 and
libwayland-client.so.0, which a desktop installation has.
Each release lists the digest of every file it publishes in verify-SHA256SUMS.txt. Download it
next to the archive, then:
sha256sum -c --ignore-missing verify-SHA256SUMS.txt # Linux
shasum -a 256 -c --ignore-missing verify-SHA256SUMS.txt # macOS
On Windows, in PowerShell, True beside the archive means it is the file the release published:
$listed = (Get-Content verify-SHA256SUMS.txt) -replace ' .*'
Get-FileHash certdoctor*.zip | ForEach-Object { "$($_.Hash -in $listed) $(Split-Path -Leaf $_.Path)" }
A digest file from the same page only proves that the two agree. What proves where they came from is a statement GitHub signed when the release was made, and the GitHub CLI checks it:
gh attestation verify <the archive> -R donislawdev/certdoctor --predicate-type https://spdx.dev/Document/v2.3
That statement is the bill of materials - every module compiled into the program, the Go toolchain
included, with its licence and version - and it covers every file of the release. The predicate
type has to be given, because without it gh asks for build provenance, which is a different
statement. A Linux archive is exactly the bytes the build produced, so the build's own
provenance answers for it too:
gh attestation verify <the archive> -R donislawdev/certdoctor
The Windows and macOS archives were signed after the build, so their bytes are no longer the
build's, and that second command finds nothing for them by design. The signature answers for them
instead: Windows checks it when the program starts, and on macOS spctl -a -vv certdoctor.app - or
certdoctor-gui.app - asks Gatekeeper.
With Go installed (1.27.4 or newer), from a clone:
git clone https://github.com/donislawdev/certdoctor
cd certdoctor
CGO_ENABLED=0 go build ./cmd/certdoctor
In PowerShell, set the variable on its own line first: $env:CGO_ENABLED = "0", then
go build ./cmd/certdoctor. CGO_ENABLED=0 gives the same static binary the releases carry, the
one that runs in an Alpine image as it is.
go install with @latest does not work here: this module's go.mod points one library of the
window at a copy kept in the repository, and go install refuses a module that replaces another. A
clone builds as above.
Copy the Linux binary in and run it there - the report then describes the container, not the host:
docker cp certdoctor my-container:/tmp/certdoctor
docker exec my-container /tmp/certdoctor https://api.example.com/
# The question this tool exists for: why does this URL fail here?
certdoctor https://api.example.com/
# A report to attach to a ticket, with host names, paths and user names masked
certdoctor https://api.example.com/ --redact --format md --out report.md
# One HTML page with no script in it and nothing fetched from anywhere
certdoctor https://api.example.com/ --format html --out report.html
# JSON for a pipeline, validated against its schema before it is printed
certdoctor https://api.example.com/ --json > report.json
# The same report as a page later, from the JSON, without running anything again
certdoctor --from report.json --format html --out report.html
# Ask about a PAC script your machine does not name, which is the only way to ask on Linux
certdoctor https://api.example.com/ --pac-url http://wpad.example.com/proxy.pacThe report opens with one sentence naming the cause in the words of the layer that decided it.
The numbers come after that sentence, then every layer with its evidence, then the notes saying what
was not looked at. Every flag is in certdoctor --help, and
the documentation walks through a report line by line.
Table of contents
If you searched for one of these, you are in the right place. certdoctor is built to tell you which of the many possible causes is yours:
PKIX path building failed
unable to find valid certification path to requested target
certificate verify failed: unable to get local issuer certificate
SELF_SIGNED_CERT_IN_CHAIN
SSL certificate problem: self signed certificate in certificate chain
x509: certificate signed by unknown authority
407 Proxy Authentication Required
They are the same complaint in the words of different runtimes, and each runtime keeps its own idea
of whom to trust. The browser works. curl works. The application does not. That is not a paradox -
it is several trust stores disagreeing.
| You saw | Usually from | What certdoctor asks |
|---|---|---|
PKIX path building failed, unable to find valid certification path |
Java | every JDK and JRE on the machine, each cacerts as its own row |
SSL certificate problem: ... |
curl, and git over HTTPS |
every git and curl it finds, and which store each of them reads |
x509: certificate signed by unknown authority |
programs written in Go | the platform store - the Windows store, or the Linux CA bundle |
407 Proxy Authentication Required |
anything going through a proxy | which proxy applies to this URL, and whether it wants credentials |
certificate verify failed: unable to get local issuer certificate |
Python, pip |
every Python on PATH, by the list its ssl module trusts, and the pip of each by its own rule - this machine's store through truststore from pip 24.2, one CA file before it |
certificate verify failed: Missing Authority Key Identifier, Basic Constraints of CA cert not marked critical |
Python 3.13 and newer | which certificate in the chain breaks which of OpenSSL's strict rules, which Python 3.13 turned on |
SELF_SIGNED_CERT_IN_CHAIN |
Node.js, npm |
every node on PATH, by the list that binary trusts - npm's own cafile is not read yet |
Each of these messages has its own page on the website: what it means, what causes it, and how certdoctor tells you which cause is yours.
A new laptop, a new network, a new VPN or a new container image, and a connection that worked yesterday fails with one of the messages above. The fix is usually one command. Finding which command takes an afternoon, because:
- every runtime has its own trust store. Java keeps one
cacertsper installation, and a typical developer machine has several JDKs, of which somebody fixed one. Git for Windows carries its own bundle.curlbuilt with Schannel reads the Windows store and ignores the bundle beside it. - the proxy is decided in several places at once. Environment variables, the Windows settings a browser follows and the different ones a service follows, and a PAC script that sends one host past the proxy everything else goes through.
- the network sees two different worlds. On a VPN one resolver answers with an internal address and another with a public one.
- nobody can tell whose fault it is. A server that forgot its intermediate certificate works in a browser that cached the intermediate from some other site and fails everywhere else.
The knowledge exists - in dozens of "every cause of PKIX path building failed" articles. What did
not exist was a tool that checks every cause on the machine in front of you and gives a verdict.
| Layer | Question it answers | Example of what it finds |
|---|---|---|
L0 Context |
which of the 25 environment variables that decide trust and proxying are set | HTTPS_PROXY set while https_proxy is not - programs disagree about which one counts |
L1 Name resolution |
what each resolver says, and what your system says | the two views sharing no address at all - what split horizon DNS looks like |
L2 Proxy |
which proxy this URL really goes through, and who decided that | a PAC script sending this host direct while every setting names a proxy |
L3 TCP |
which of the resolved addresses accept a connection | refused, unreachable and timed out told apart, per address |
L4 TLS |
what the server presented, and whether that chain is complete | a server that did not send its intermediate certificate |
L5 Trust matrix |
which trust stores on this machine accept that chain | one JDK refusing a root that another JDK and the Windows store accept |
The handshake is completed without verifying it, on purpose. A chain that fails verification leaves you with no certificates to look at, and here the evidence is the product.
The Windows store is asked with every network retrieval switched off. The address Windows would fetch a missing certificate from is written into that certificate by the server you are diagnosing, and certdoctor connects only where you pointed it. It tells a public root Windows has simply not downloaded yet from an authority nobody installed by reading this machine's own copy of Microsoft's root program list.
A store that cannot be read says so, with the reason, and never counts as a pass. A probe that
does not understand what it saw reports UNKNOWN. There are exactly four statuses - PASS, WARN,
FAIL, UNKNOWN - and UNKNOWN always carries why.
Other tools look at the certificate. certdoctor looks at the machine in front of you and at which of its runtimes accept that certificate.
It does not compete with these, and links to them on purpose:
- certigo - inspects and explains certificates
- testssl.sh - audits the TLS configuration of a server
- tlstools - certificate utilities
When the trouble is the server, they tell you more about the server than certdoctor does. When the trouble is that one runtime on your machine disagrees with another, they cannot see it at all.
Every ending has its own exit code, and --json writes a report validated against its schema before
anything is printed:
| Code | Name | Meaning |
|---|---|---|
0 |
OK | everything that mattered was assessed and passed |
1 |
RUNTIME | certdoctor itself failed |
2 |
USAGE | bad invocation syntax or an unknown flag |
3 |
CONFIG | flags are valid on their own but contradictory or unsatisfiable |
4 |
DIAGNOSIS | the run succeeded and found at least one failure |
5 |
INCONCLUSIVE | no failure, but at least one thing could not be assessed |
6 |
IO | the report could not be written |
130 |
INTERRUPTED | interrupted by the user |
143 |
TERMINATED | terminated by the system |
A WARN alone does not fail a build. certdoctor --version prints the version of the tool and of
every schema it emits, so a log from six months ago still says what produced it.
--from turns a saved --json report into another format and ends with 0 once it is written,
whatever the report found, so a later step that only makes a page does not fail the build a second
time. The verdict's own code stays in the report as verdict.exit_code. On Windows PowerShell 5.1,
save the JSON with --out rather than >, which writes UTF-16 there.
- name: can the build agent reach the registry
run: certdoctor https://registry.example.com/ --redact --json --out certdoctor.jsonBeside the diagnosis, certdoctor has small tools for what stands around a connection. Each reads
only what you give it, connects to nothing and changes nothing. certdoctor tool lists them, and
the window has them under Tools on its bar.
The certificate decoder reads a certificate or a whole chain - a .pem or .crt file, the
output of openssl s_client -showcerts, base64 of one certificate or an x5c array, or a DER file -
and shows each certificate: who it is for and who issued it, its host names, when it is valid and
how long is left, its key and signature, and the fingerprints you compare elsewhere - SHA-256, the
SHA-1 Windows shows as the thumbprint, and the SPKI pin used for pinning. It says whether the chain
is complete and in order, and warns about an expired certificate, an MD5 or SHA-1 signature, a short
RSA key and a server certificate with no host name in it. A private key pasted along with the
certificates is not read and not shown. It checks nothing against a trust store - the diagnosis
does that for a server.
certdoctor tool cert --file chain.pem
openssl s_client -showcerts -connect example.com:443 </dev/null | certdoctor tool cert
certdoctor tool cert --json < server.crtA chain has many lines and no single Enter that ends it, so it is piped in or named with --file
rather than pasted at a prompt.
The JWT decoder shows a token's header and claims in the order the token has
them, says when the token was issued, from when it is valid and when it expires, and warns about
what makes libraries disagree: alg: none, a stripped or wrongly shaped signature, a name given
twice, padding or standard base64. It never checks the signature - that needs the issuer's key -
and says so every time.
certdoctor tool jwt # paste the token, then press Enter
certdoctor tool jwt --file token.txt
certdoctor tool jwt --json < token.txtA token is never taken as an argument: an argument stays in your shell's history and in the list
of running programs. Bearer in front and quotes around it are taken off, and the output says so.
On Linux and macOS nothing appears while you paste: the terminal's own line handling cuts a long
line or stops taking keys, so the token is read key by key, and after Enter the command says how
many characters it read.
Every tool ends with 0 whenever it read its input, whatever it found there, and with 3 when
what it was given is not something it can read.
A tool that quietly fails to look at something is worse than one that says what it cannot do.
- The trust matrix knows seven families today - the platform store on Windows and Linux, Java,
git,curl, Node.js, Python andpip. OpenSSL comes next, then .NET and PowerShell, Docker, Maven and Gradle, and apt. A Python started through an app alias inWindowsAppswas measured with the Python install manager, not with Python from the Microsoft Store. OpenSSL's strict rules are applied as OpenSSL 3 has them, and two of them were measured against Python - the rest are read from OpenSSL's source. A family it does not know is not asked, and the report lists the families it asked - so a missing row means nobody looked, never that nothing is there. - On macOS the system keychain is not read yet, so the platform store has no row there.
- Container diagnosis is not built.
--in,--imageand--compareare accepted and refused with a reason. Until they work, copy the binary into the container and run it there. - Proxy auto-discovery (WPAD) is not performed. On a machine where it is switched on, the proxy layer says it could not establish which proxy applies, rather than claiming there is none - so a healthy target there ends with exit code 5, not 0.
- It names an inspection product only when the certificate says so in a way the vendor publishes - today mitmproxy and Cisco Umbrella. Anything else is reported as an authority local to this machine, held by some of its stores, with the issuer and the fingerprint. From one connection, an inspecting proxy and your own company's certificate authority look identical, and telling somebody "you are being intercepted" when they are not is harm, not a cosmetic mistake.
- No revocation checks. OCSP and CRL would mean connecting somewhere you did not point it at.
- Linux has been measured in containers, not on a desktop distribution.
- The window exists for three systems - Windows and Linux on x64, macOS on Apple silicon. On Windows or Linux on Arm, use the command line.
- This is a 0.x release. The JSON schema will keep moving until 1.0.0, and the report says so in its notes.
certdoctor diagnoses. It does not repair.
It never writes to a trust store, never edits a configuration file, and never runs the commands it generates. It prints them for you to read and run yourself. This is a deliberate limit, not a missing feature - a diagnostic tool that quietly modifies trust stores is a tool nobody should grant administrator rights to.
It also connects only to what you pointed it at - the target URL, the addresses it resolves to, your
configured resolvers and proxies, and the PAC file your machine names or you name with --pac-url.
There is no telemetry, no update check and no reporting of any kind.
The report includes internal host names, file system paths and user names, because that is what makes
it useful in a ticket. --redact masks them.
Credentials are different: proxy user info, authorization headers and cookies are always masked,
with or without --redact, because a masking that depends on a flag is one forgotten flag away from a
leak.
Read a report before you paste it somewhere. Masking is a filter, not a guarantee.
No. It reads, connects where you pointed it, and writes one report if you ask for one with --out.
The commands it prints are for you to run, after reading them.
The window keeps one thing for itself: the folder you last saved a report to, in a file called
preferences.json in your user folder - AppData\Roaming\fyne\dev.donislaw.certdoctor on Windows,
~/.config/fyne/dev.donislaw.certdoctor on Linux (under $XDG_CONFIG_HOME when that is set) and
~/Library/Preferences/fyne/dev.donislaw.certdoctor on macOS. Fyne, the toolkit the window is built
with, creates that folder when the window starts, and an empty one of the same name in your user cache
folder. Delete both when you remove the window.
Both answer for themselves. openssl s_client tells you what OpenSSL's store thinks, and the Java
service that fails does not use that store. certdoctor asks every store it can find the same
question about the same chain, and the answer is usually that they disagree.
Only when the certificate names a product it recognises. Otherwise it says who signed the certificate and how many stores on your machine trust that authority - which is exactly what your own company's certificate authority looks like too.
No. It runs as whoever started it, and the report records which rights it ran with, because what a store looks like can depend on them. A printed fix says when running it needs administrator or root rights.
Yes to both. It is GPL-3.0 and costs nothing. The reports you generate belong to you.
That is the most useful issue you can open. Attach the report made with --redact --json, say what
the right answer was, and name the runtime that disagreed - the
Wrong answer form
asks for each of them. Security problems go through SECURITY.md instead.
Copyright (C) 2026 DonislawDev. The project is built with an AI-assisted workflow.
Released under the GNU General Public License, version 3. The full text is in LICENSE. The
binary also contains code by others - the Go runtime and five modules behind the PAC evaluator - and
their notices are in THIRD-PARTY-NOTICES.md. certdoctor --license prints
both from the binary. The window contains more - Fyne and the libraries it draws with, the Inter
typeface, and on Windows the Mesa software renderer next to it - and its notices are in
THIRD-PARTY-NOTICES-GUI.md.
Reports and any other output you generate with certdoctor belong to you and are not derivative works of this program.
If it saves you an afternoon, a star helps the next person find it - and you can support the project.
Keywords: PKIX path building failed · unable to find valid certification path to requested target · certificate verify failed unable to get local issuer certificate · x509 certificate signed by unknown authority · SSL certificate problem self signed certificate in certificate chain · corporate proxy certificate error · TLS inspection troubleshooting · keytool import certificate into cacerts · which JDK cacerts · git SSL certificate problem Windows · PAC file test · 407 Proxy Authentication Required
