- Python 76.2%
- HTML 23.2%
- Dockerfile 0.6%
Three related improvements that surfaced from analyzing an AD CS site (eskilstuna.se) carrying both V0.0 and V1.1 CA artefacts: * CA Version (MS OID 1.3.6.1.4.1.311.21.1) extracted on certs and CRLs and rendered as "<key>.<cert>" alongside other identifiers. Lets operators distinguish prior-generation files at a glance — a CRL with a different CA Version than its issuing cert was signed by an older key, even when the subject DN matches. * CRLInfo.age_percent computed server-side from full UTC datetimes, not from the date-floored this_update/next_update strings the UI used to recompute from. Short windows like hourly delta CRLs no longer collapse to 0% or 100% at the day boundary; the >90% threshold now fires accurately for sub-day "less than 10% left" cases instead of yellowing every short-window CRL indiscriminately. Required fixing a pre-existing _crl_next_publish bug that ignored UTCTime-encoded values (AD CS emits these for pre-2050 dates). * Prior-generation CRLs now route to orphan_crls instead of attaching to a same-named different-key CA via DN fallback. Each orphan carries ca_version and a 'key_mismatch' / 'unknown_issuer' reason driving tailored remediation text. CRL_ISSUER_ABSENT findings are emitted from the orphan path too so they appear in the Issues panel — the previous orphan-only routing would have silently demoted them out of the at-a-glance findings count. Composes with eb26e0e's validate_keys mechanism, which still handles the no-AKI DN-fallback case attached under the CA. AIA_STALE_RENEWAL text rewritten to include CA Version on both the served and chain certs, explain why same-key reissuance still validates today, and call out when AIA-built chains will break (the served cert's expiry date). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|---|---|---|
| templates | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| .python-version | ||
| analyzer.py | ||
| app.py | ||
| CLAUDE.md | ||
| Dockerfile | ||
| pki_check.py | ||
| pyproject.toml | ||
| README.md | ||
| scan_kommuner.py | ||
| uv.lock | ||
PKI Chain Analyzer
Analyzes certificate chains for public PKI infrastructure using AIA and CDP distribution points. Checks chain integrity, CRL health, and AIA reachability without requiring access to private keys or internal systems.
What it analyzes
Give it a PKI distribution point, an HTTPS server, or a certificate file and it will:
- Walk the full CA hierarchy from leaf to root via AIA CA Issuers
- Validate each certificate's expiry, key usage, and AKI/SKI chain linkage
- Fetch and verify every CRL: reachability, expiry, AKI match, and signature
- Verify AIA URLs serve the correct issuer certificate
- Identify orphaned CRLs, end-entity certificates on CA distribution points, and stale renewals
- Report all issues with severity, detail, and remediation guidance
Usage
Web UI
https://labb.selmosson.cloud/pki-analyzer/
Enter any of:
- A PKI distribution point:
http://pki.example.com/ - An HTTPS server:
https://example.com - A direct certificate URL:
http://pki.example.com/ca.crt
Results are shareable via the Share button — the URL encodes the analyzed target.
CLI
uv run pki_check.py <url-or-path> [<url-or-path> ...]
uv run pki_check.py --safe <url> # enable SSRF protection
Exits 0 if no errors, 1 if any error finding is detected. Suitable for monitoring scripts.
Running locally
uv run uvicorn app:app --host 0.0.0.0 --port 8000
Deployment
The Docker image targets linux/amd64. Build from macOS (arm64) using buildx:
docker buildx build --platform linux/amd64 --push -t git.selmosson.cloud/js/pki-analyzer:latest .
First-time buildx setup:
brew install docker-buildx
mkdir -p ~/.docker/cli-plugins
ln -sf $(brew --prefix)/opt/docker-buildx/bin/docker-buildx ~/.docker/cli-plugins/docker-buildx
Served at /pki-analyzer/ behind Caddy:
handle /pki-analyzer/* {
uri strip_prefix /pki-analyzer
reverse_proxy pki-analyzer:8000
}
Configuration
Web app environment variables:
| Variable | Default | Description |
|---|---|---|
FORWARDED_ALLOW_IPS |
127.0.0.1 |
Upstreams Uvicorn trusts for X-Forwarded-For. Never set to * — the rate limiter keys on the forwarded IP, so a wildcard lets any client spoof it. |
DB_PATH |
/data/pki_analyzer.db |
SQLite request log location. |
LOG_PATH |
(unset) | URL path for the log viewer, e.g. /audit-abc123. Omit to disable the endpoint entirely. |
LOG_USER / LOG_PASSWORD |
(unset) | HTTP Basic Auth for the log viewer. All three log-viewer variables must be set together. |
CLI flags for scan_kommuner.py: --clear-cache (delete cache and exit), --no-cache (skip read+write), --cache-file PATH (override). Cache defaults to $XDG_CACHE_HOME/pki-analyzer/scan_kommuner.json.
Security
SSRF protection is enabled by default in the web app. It blocks requests to private IP ranges (RFC 1918, loopback, link-local) and single-label hostnames. The CLI requires --safe to enable it.
The analyzer also:
- Pins resolved IPs across the entire fetch so DNS rebinding can't sneak a private address in after the safety check
- Follows redirects manually, re-running the safety check on every hop
- Strips userinfo (
user:pass@host) before the URL reaches the analyzer or the request log - Sets
Content-Security-Policy,X-Content-Type-Options,X-Frame-Options: DENY, andReferrer-Policy: no-referreron every response
Certificate validation is intentionally disabled during TLS connections so broken or misconfigured chains can still be analyzed.
Tests
uv run pytest
Covers SSRF protection (localhost block, redirect bypass, DNS rebinding, directory-scrape allowlist), URL-credential stripping, analysis cancellation, AIA depth findings, the validity-display threshold, and security headers.