证书透明度日志监控是一套针对 HTTPS 证书签发行为的第三方审计机制。只要企业域名存在公开 CA 误签、内部私签证书泄露、离职人员抢注子域证书等情况,证书透明度(Certificate Transparency,CT)日志里就会留下不可篡改的记录。本文给出一套可落地的证书透明度监控方案:从 crt.sh 拉取基线、编写差异比对脚本、配置 CAA 策略限制可签发 CA,到证书滥用后的应急处置,命令与代码均可直接复制执行。
适用场景
- 企业域名单一入口由多个部门管理,需要发现「谁在什么时候为哪个子域申请了证书」。
- 需要及时发现未授权 CA 签发的证书,防止钓鱼站、供应链仿冒站冒用企业品牌域名。
- 资产台账不完整,想通过 CT 日志反查历史签发过的全部子域,补齐影子资产清单。
- 安全运营要求把「证书异常签发」纳入日常告警,与 WAF、日志审计形成联动。
前置条件
- 一份完整的企业主域名与关键子域清单(如
example.com、*.example.com)。 - 一台可访问公网的 Linux 服务器(用于定时任务),安装
python3、curl、jq。 - 一个告警通道:企业邮箱 SMTP、Webhook(企业微信/钉钉机器人)或工单系统 API。
- 域名 DNS 可编辑权限(用于配置 CAA 记录)。
- 可选:CT 实时流服务(CertStream)或自建 CT 镜像,用于降低对 crt.sh 的依赖。
原理说明
CT 的规范定义在 RFC 6962。其核心设计是:所有公开信任的 CA 在签发证书时,必须把证书提交到一个或多个 CT 日志服务器,日志服务器把证书追加到一个仅可追加(append-only)的 Merkle 树中,并返回一个签名确认(SCT,Signed Certificate Timestamp)。主流浏览器要求 TLS 证书至少携带两个来自合格日志的 SCT,否则会弹出不安全警告。这意味着任何面向浏览器可用的证书,其签发记录都必然公开可查。
监控的技术路径有三条:
- crt.sh 查询:通过
https://crt.sh/?q=%25.example.com&output=json返回该域名(含子域)的全部签发记录,字段包含颁发者、名称、有效期、序列号、条目时间戳,适合做定期全量快照与差异比对。 - CertStream 实时流:订阅 WebSocket 事件流,近实时获取全球新签发证书,适合做「我方域名第一时间告警」,但数据量大、噪声多。
- CAA 记录:DNS 中的 CAA 资源记录用于声明「本域名只允许哪些 CA 签发」,是事前约束,与事后监控互补。
需要说明的是,CT 监控解决的是签发环节的可见性,它无法阻止证书被签发,只能让你更快发现并处置;真正的事前限制依赖 CAA 与 CA 侧的域名验证强度。
操作步骤
步骤一:建立 CT 基线快照
# 安装依赖
sudo apt update && sudo apt install -y curl jq python3 python3-pip
# 查询 example.com 及其全部子域的签发记录(%25 即 URL 编码的 %)
curl -s "https://crt.sh/?q=%25.example.com&output=json" \
| jq -r '.[].name_value' | tr ',' '\n' | sort -u
# 查看包含颁发者与有效期的完整记录
curl -s "https://crt.sh/?q=%25.example.com&output=json" \
| jq -r '.[] | [.issuer_name, .common_name, .not_before, .not_after, .serial_number] | @tsv' \
| sort -u | head -40
步骤二:编写监控与差异比对脚本
脚本每次运行时拉取全量记录,与上一次基线做集合差异,新增证书即触发告警。注意通配符证书的 name_value 会包含 *.example.com,需要进行规范化处理。
cat > /opt/ct-monitor/ct_monitor.py <<'PY'
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""证书透明度 CT 日志监控:拉取 crt.sh 记录并与基线比对,新增即告警。"""
import json, os, sys, time, urllib.request, urllib.error, subprocess
DOMAINS = ["example.com"] # 待监控的主域名
BASE_DIR = "/opt/ct-monitor/state"
APPROVED_CA = ["Let's Encrypt", "DigiCert", "GlobalSign", "Sectigo"]
WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
UA = {"User-Agent": "ct-monitor/1.0"}
def fetch(domain, retry=3):
"""查询 crt.sh,带指数退避重试。"""
url = "https://crt.sh/?q=%25.%s&output=json" % domain
for i in range(retry):
try:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
except (urllib.error.HTTPError, urllib.error.URLError,
json.JSONDecodeError) as e:
print("fetch retry %d: %s" % (i + 1, e), file=sys.stderr)
time.sleep(2 ** i * 5)
return []
def normalize(rec):
"""把一条 CT 记录归一化为可比较的指纹。"""
names = set()
for n in (rec.get("name_value") or "").split("\n"):
n = n.strip().lower().rstrip(".")
if n:
names.add(n)
return {
"id": rec.get("id"),
"serial": (rec.get("serial_number") or "").lower(),
"issuer": rec.get("issuer_name") or "",
"names": sorted(names),
"not_before": rec.get("not_before"),
"not_after": rec.get("not_after"),
}
def alert(text):
payload = json.dumps({"msgtype": "text", "text": {"content": text}}).encode()
try:
urllib.request.urlopen(urllib.request.Request(
WEBHOOK, data=payload,
headers={"Content-Type": "application/json"}), timeout=20)
except Exception as e:
print("alert failed: %s" % e, file=sys.stderr)
def main():
os.makedirs(BASE_DIR, exist_ok=True)
total_new, messages = 0, []
for domain in DOMAINS:
raw = [normalize(r) for r in fetch(domain)]
seen, uniq = set(), []
for r in raw:
key = r["serial"] or ",".join(r["names"])
if key and key not in seen:
seen.add(key)
uniq.append(r)
path = os.path.join(BASE_DIR, domain.replace(".", "_") + ".json")
old = json.load(open(path, encoding="utf-8")) if os.path.exists(path) else []
old_keys = {r["serial"] or ",".join(r["names"]) for r in old}
for r in uniq:
key = r["serial"] or ",".join(r["names"])
if key in old_keys:
continue
total_new += 1
risk = "正常" if any(c in r["issuer"] for c in APPROVED_CA) else "异常CA"
messages.append(
"[%s] %s\n 名称: %s\n 颁发者: %s\n 生效: %s"
% (risk, domain, ", ".join(r["names"]), r["issuer"], r["not_before"]))
json.dump(uniq, open(path, "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
print("[%s] 当前 %d 条,新增基线已更新" % (domain, len(uniq)))
if messages:
alert("证书透明度监控告警:新增 %d 条签发记录\n\n%s"
% (total_new, "\n\n".join(messages[:20])))
else:
print("无新增签发记录")
if __name__ == "__main__":
main()
PY
chmod +x /opt/ct-monitor/ct_monitor.py
/opt/ct-monitor/ct_monitor.py
步骤三:配置 CAA 记录限制可签发 CA
CAA 属于事前约束,建议对所有对外域名配置,只允许白名单 CA 签发,并配置违规上报地址。
# 示例:仅允许 Let's Encrypt 签发,并声明违规上报邮箱
example.com. IN CAA 0 issue "letsencrypt.org"
example.com. IN CAA 0 issuewild "letsencrypt.org"
example.com. IN CAA 0 iodef "mailto:security@example.com"
# 校验记录是否生效(等待 DNS 生效后执行)
dig +short CAA example.com
dig +short CAA www.example.com
步骤四:加入定时任务与实时流补充
# 每天 07:00 执行一次全量比对(crontab)
0 7 * * * /usr/bin/python3 /opt/ct-monitor/ct_monitor.py >> /var/log/ct-monitor.log 2>&1
# 可选:订阅 CertStream 实时流,仅匹配我方域名关键词
pip3 install certstream
python3 - <<'PY'
import certstream
KEYWORDS = ["example.com"]
def cb(message, context):
if message.get("message_type") != "certificate_update":
return
d = message["data"]["leaf_cert"]
names = set(d.get("all_domains") or [])
if any(any(k in n for n in names) for k in KEYWORDS):
print("实时命中:", d.get("fingerprint"), sorted(names))
certstream.listen_for_events(cb, url="wss://certstream.calidog.io/")
PY
步骤五:异常签发后的应急处置
- 核对新证书的域名与业务归属,确认是否为已知部门申请。
- 若为非授权签发:记录序列号与 SCT,向颁发 CA 提交误签发报告(Mis-issuance Report),要求吊销。
- 检查该域名是否被用于钓鱼或仿冒,提交至浏览器厂商与安全厂商的恶意域名黑名单。
- 复核 DNS 账号权限与域名验证方式,收紧 CAA 与账户多因素认证。
配置验证
- 手动触发一次脚本:首次运行应把全部记录写为基线文件(位于
/opt/ct-monitor/state/),第二次运行输出「无新增签发记录」。 - 模拟验证:临时在
DOMAINS中加入一个新域名后运行,应看到新增记录与告警内容,确认告警通道可用。 - CAA 校验:
dig +short CAA example.com返回预期记录,且证书签发仍可正常完成。 - 日志校验:
/var/log/ct-monitor.log中有每日执行记录,无 Python 异常堆栈。
常见问题(FAQ)
Q1:crt.sh 查询经常超时或返回 502,脚本总是拉不到数据怎么办?
crt.sh 为公共服务,存在限流与不稳定情况。脚本已内置三次指数退避重试。进一步可采取:放慢轮询频率(改为每天一次而非每小时)、按单个子域拆分查询降低单次响应体、或改用 CertStream 实时流与 certspotter 等替代数据源。
# 放大超时并限制重试间隔;也可通过 -m 限制最大返回条目
curl -s --max-time 120 --retry 5 --retry-delay 10 \
"https://crt.sh/?q=%25.example.com&output=json" -o /tmp/ct.json
jq 'length' /tmp/ct.json
Q2:泛域名证书的 name_value 含 *.example.com,导致差异比对误报怎么办?
需要做规范化:统一转小写、去掉尾部点号、拆分多行名称、对通配符保留原样参与比较,同时以 serial_number 作为主键而非域名集合,避免同一张证书因域名解析顺序不同被判定为新增。推荐以序列号为主键、域名集合为辅助字段。
# 以序列号去重,观察是否仍有重复条目
jq -r '.[].serial_number' /tmp/ct.json | sort | uniq -c | sort -rn | head
Q3:CT 监控能否阻止证书被签发?
不能。CT 只提供事后可见性。要在签发环节施加限制,必须依赖 CAA 记录(声明允许的 CA)以及 CA 侧使用的域名验证方式(如 DNS TXT 验证比 HTTP 文件验证更难被旁路)。CT 与 CAA 的正确关系是「事前约束 + 事后发现」,两者需同时配置。
总结
证书透明度日志监控的价值在于把「证书被签发」这一原本不可见的事件变成可审计、可告警的信号。落地要点有三:一是用 crt.sh 建立全量基线并做序列号级差异比对,避免通配符与重复条目造成误报;二是用 CAA 记录在事前收窄可签发 CA 范围;三是把新增签发纳入告警通道并预留误签发处置流程。对企业而言,CT 监控同时还是影子资产发现的顺手工具——历史上被签过证书的子域,往往就是台账里缺失的那些资产。
建议与证书到期监控、TLS 配置基线检查配合使用:CT 负责「谁签了证书」,到期监控负责「证书是否即将失效」,配置基线负责「加密套件与协议版本是否合规」,三者构成完整的证书生命周期治理闭环。