New to Kestra?
Use blueprints to kickstart your first workflows.
Watch Certificate Transparency logs with Kestra. Find hostnames under your domains that nobody registered, unapproved CAs plus new wildcards.
An expiry monitor checks hostnames you already know about. This flow returns hostnames you did not know about, read out of a public append-only log that no probe of your own network can reveal. Every publicly trusted certificate authority is required to log what it signs, so Certificate Transparency is the one place a forgotten staging host, a shadow IT subdomain, a wildcard somebody issued last night plus a certificate authority you never approved all show up in the same feed.
Run against kestra.io for a minute and the point lands. The log hands back develop.registry.internal.kestra.io, kibana.prod.internal.gcp.kestra.io, bookmarks.internal.kestra.io, 23 more names with internal in them, 10 wildcard issuances for *.kestra.io, plus two certificates for support.kestra.io signed by Amazon Trust Services while every other certificate in that page came from Let's Encrypt or Google Trust Services. No port scan finds those names. No DNS zone transfer gives them to you. A public log does.
This blueprint was created by zkasuran.
expected_hostnames nor in the per domain memory the flow keeps in the KV store.issuer.friendly_name outside approved_issuers. This check is on purpose not suppressed by the hostname memory, because a certificate authority you do not use signing a hostname you already run is the mis-issuance case.*. name the flow has not recorded before. One wildcard quietly covers every host in the zone.Cert Spotter returns the oldest issuance first, not the newest. So the first executions are a backfill, not a watch:
BASELINE SEEDED. Nobody is paged.BACKFILL. Still nobody is paged.CAUGHT UP and sets at_head. kestra.io took two pages.WATCHING, findings alert.The baseline run is noisy by design, because on a first look every hostname is unknown. Read it as an inventory, then move the names you recognise into expected_hostnames. Certificates shared with a CDN or a SaaS put names outside your zone in the list as well: kestra.io pulls in c837a3a0.sni.cloudflaressl.com, which is Cloudflare, not an intruder. *.cloudflaressl.com in expected_hostnames silences that whole class.
The keyless Cert Spotter tier allows 10 requests per hour per egress IP address, measured from the x-ratelimit-limit response header. The flow spends exactly one request per domain per run, loops domains serially then runs hourly. There is no flow-level concurrency limit on purpose: each Loop iteration runs as a sub-execution of this same flow, and a flow limit of 1 leaves the first iteration queued behind its own parent forever. A run takes seconds, so hourly schedules do not overlap. HTTP 429 is an allowed response code rather than a crash: the run logs RATE LIMITED, leaves the cursor untouched, exits green.
SSLMate describes the keyless tier as "for personal or evaluation purposes" and asks production users to authenticate. The fetch_issuances task carries the authenticated header ready to uncomment:
headers:
Authorization: "Bearer {{ secret('CERTSPOTTER_API_KEY') }}"
watch_domains (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) takes one domain at a time.read_cursor plus read_seen (io.kestra.plugin.core.kv.Get, errorOnMissing: false) load the opaque issuance id this domain reached plus its hostname memory. Neither key gets a ttl: kv.Get throws on an expired key whatever errorOnMissing says.fetch_issuances (io.kestra.plugin.core.http.Request) asks for one page. The next page URI is built from the stored id, because the server's own Link: rel="next" header omits its /v1 prefix then returns 404. One line of comment on the task says so.classify_issuances (io.kestra.plugin.scripts.python.Script on ghcr.io/kestra-io/pydata:latest, Python standard library only) splits the page three ways, computes the next cursor, returns the updated memory through Kestra.outputs.report (io.kestra.plugin.core.log.Log) prints the verdict on every run, backfill runs included, with the remaining request budget read off the response header.save_seen (io.kestra.plugin.core.kv.Set) persists the memory before any alert, so a Slack outage cannot wedge the flow on one page.advance_cursor (io.kestra.plugin.core.flow.If) writes the cursor only when the page produced one. An empty page or a 429 leaves a good cursor alone.alert (io.kestra.plugin.core.flow.If) gates io.kestra.plugin.slack.notifications.SlackIncomingWebhook on at_head, on findings existing, plus on notify_slack.domains (ARRAY of STRING, default ["kestra.io"]): registrable domains you own. One request each per run.include_subdomains (BOOL, default true): subdomains are where the surprises live.expected_hostnames (ARRAY of STRING, default []): names you already run. A *.example.com entry covers example.com plus every depth below it, so use it only for a zone you hand to someone else. Allowlisting your own apex wildcard switches the signal off.approved_issuers (ARRAY of STRING, default ["Let's Encrypt", "Google Trust Services"]): expected issuer.friendly_name values.max_known_hostnames (INT, default 500): ceiling on the per domain memory. Oldest first out, so a dropped name can be reported once more later. Raise it or move names into expected_hostnames.notify_slack (BOOL, default false): Slack on top of the log.api.certspotter.com. No account, no key, nothing to register.SLACK_WEBHOOK_URL: only when notify_slack is true.CERTSPOTTER_API_KEY: only if you uncomment the authenticated header.domains to a domain you own, leave everything else alone.BASELINE SEEDED with your hostname inventory.CAUGHT UP. Two or three runs is typical.expected_hostnames.notify_slack on with the webhook secret set, then leave the hourly schedule running.approved_issuers then re-run. Every certificate it signed is reported.outputs.classify_issuances.vars per domain:
phase: BASELINE SEEDED, BACKFILL, CAUGHT UP, WATCHING or RATE LIMITED.records, distinct_hostnames: size of this page.unregistered_count plus unregistered: hostnames nobody registered.unapproved_count plus unapproved: hostname (issuer) pairs outside the allowlist.new_wildcard_count plus new_wildcards.cursor_before, cursor_after, at_head, known_count, known_dropped, alertable.KV keys per domain: ct_cursor_<domain> holds the opaque id, ct_seen_<domain> holds {"at_head": bool, "known": [...]}. Delete both to start over.
api.certspotter.com is the only source. crt.sh was the obvious second opinion, it returned HTTP 502 on every attempt, so it is not wired in.revoked to the findings. The field is already in every record.dns_names against your DNS zone or your Terraform state rather than a hand written expected_hostnames.