证书透明度CT日志监控实战:从检测到告警的完整方案
为什么需要CT日志监控
证书透明度(Certificate Transparency,简称CT)于2014年由Google发起,旨在解决CA机构可能滥用或误发证书的安全隐患。RFC 6962定义了CT的技术框架,CAB Forum的Baseline Requirements(BR)第1.6.1条强制要求所有公开信任的SSL/TLS证书必须提交到至少一个CT日志。
但提交不等于合规。2021年DigiCert曾发生长达数月的证书误发事件,部分问题证书直到安全研究人员主动监控CT日志才发现。对大型企业而言,CT日志是唯一客观、不可篡改的证书签发审计源——你的CA签发了什么证书、何时签发、是否满足CT策略,都必须通过CT日志来验证。
本文聚焦一个常被忽视的工程实践:如何构建自动化的CT日志监控管道,实时检测异常签发、验证合规性、生成审计报告。
CT日志的基本结构
CT日志本质上是一个Merkle Tree审计日志,每个节点记录一次证书颁发事件。核心数据结构:
Log Entry = {
timestamp: RFC3339,
leaf_input: x509_certificate + sct_list,
extra_data: optional extension,
tree_size: uint64,
signature: LogPrivateKey.Sign(MerkleTreeHead)
}每个条目通过Merkle Tree哈希链接到前一个条目,形成不可篡改的链式结构。验证单条日志条目时,只需提供该条目的Merkle Tree证明(O(log N)个哈希值),无需下载整个日志。
关键API端点
主流CT日志提供商(Google、Sectigo、Cloudflare)均实现了RFC 6962定义的JSON API:
| API | 路径 | 用途 |
|---|---|---|
| get-sth | /ct/v1/get-sth | 获取当前日志状态(树大小+根哈希) |
| get-entries | /ct/v1/get-entries | 批量获取日志条目(按索引范围) |
| get-proof-by-hash | /ct/v1/get-proof-by-hash | 通过证书指纹查找Merkle证明 |
| get-verification-time | /ct/v1/get-verification-time | 验证特定时间点的日志状态 |
https://ct.googleapis.com/ct/v1/get-roots。异常签发检测算法
检测类型定义
企业CT监控需要检测以下几类异常:
| 检测类型 | 触发条件 | 风险等级 |
|---|---|---|
| 未授权域名签发 | 证书SAN包含企业域名,但不在申请清单中 | 高 |
| 超期证书签发 | 证书有效期 > 企业内部策略上限 | 中 |
| CA违规签发 | 证书由非授权CA签发 | 高 |
| SCT缺失 | 证书未包含有效SCT | 高 |
| 重复域名检查 | 同一域名短时间内多次签发 | 低 |
| 子域名枚举 | 攻击者尝试签发企业子域名证书 | 中 |
核心检测流程
┌─────────────┐
│ CT日志API │
└──────┬──────┘
│ get-entries (by tree_size)
▼
┌─────────────┐
│ 批量解析 │
│ 证书/SCT │
└──────┬──────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 域名清单 │ │ CA白名单 │ │ 策略规则 │
│ 比对 │ │ 比对 │ │ 检查 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└───────────────┼───────────────┘
▼
┌─────────────┐
│ 告警输出 │
│ JSON/邮件 │
└─────────────┘完整实现代码
基础CT日志查询器
以下代码实现了RFC 6962兼容的CT日志查询器,支持获取日志状态、批量拉取条目、验证Merkle证明。
"""
CT Log Monitor - RFC 6962 Compatible CT Log Query and Audit Tool
Requirements:
pip install requests pyOpenSSL cryptography
"""
import json
import time
import hashlib
import base64
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, field
import requests
from cryptography import x509
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
@dataclass
class LogState:
"""Current state of a CT log (STH - SignedTreeHead)."""
tree_size: int
tree_head_hash: bytes
root_hash: bytes
timestamp: int
signature: bytes
@property
def next_entry_index(self) -> int:
return self.tree_size
@dataclass
class LogEntry:
"""A single entry in the CT log."""
index: int
leaf_input: bytes
extra_data: bytes
timestamp: int
certificate: Optional[x509.Certificate] = None
scts: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class MerkleProof:
"""Merkle tree inclusion proof for a log entry."""
leaf_hash: bytes
node_hashes: List[bytes]
tree_size: int
class CTLogClient:
"""
Client for querying RFC 6962 CT logs.
Public log endpoints:
- Google: https://ct.googleapis.com/logs/argon2024
- Sectigo: https://ct.sectigo.com/logs/mercure2024
- Cloudflare: https://ct.cloudflare.com/logs/nimbus2024
"""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip('/')
self.session = requests.Session()
self.session.headers.update({
'Accept': 'application/json',
'User-Agent': 'CT-Log-Monitor/1.0'
})
def get_sth(self) -> LogState:
"""Get the current SignedTreeHead from the log."""
resp = self.session.get(f'{self.base_url}/ct/v1/get-sth')
resp.raise_for_status()
data = resp.json()
return LogState(
tree_size=int(data['tree_size']),
tree_head_hash=base64.b64decode(data['tree_head_sha256']),
root_hash=base64.b64decode(data['root_hash']),
timestamp=int(data['timestamp']),
signature=base64.b64decode(data['signature'])
)
def get_entries(self, start: int, end: int) -> List[LogEntry]:
"""
Fetch log entries by index range (inclusive).
Args:
start: Starting index (0-based)
end: Ending index (inclusive)
Returns:
List of LogEntry objects
"""
entries = []
# RFC 6962 recommends fetching in batches of 256
batch_size = 256
for batch_start in range(start, end + 1, batch_size):
batch_end = min(batch_start + batch_size - 1, end)
resp = self.session.get(
f'{self.base_url}/ct/v1/get-entries',
params={'start': batch_start, 'end': batch_end}
)
resp.raise_for_status()
data = resp.json()
for entry_data in data.get('entries', []):
entry = self._parse_entry(entry_data)
entries.append(entry)
# Rate limiting: respect log server constraints
time.sleep(0.1)
return entries
def get_proof_by_hash(self, entry_hash: bytes) -> MerkleProof:
"""
Get Merkle inclusion proof for an entry by its leaf hash.
Args:
entry_hash: SHA256 hash of the leaf_input (base64 encoded)
"""
resp = self.session.get(
f'{self.base_url}/ct/v1/get-proof-by-hash',
params={'leaf_hash': entry_hash}
)
resp.raise_for_status()
data = resp.json()
return MerkleProof(
leaf_hash=base64.b64decode(data['leaf_hash']),
node_hashes=[base64.b64decode(h) for h in data.get('tree_size', [])],
tree_size=int(data['tree_size'])
)
def _parse_entry(self, entry_data: Dict[str, str]) -> LogEntry:
"""Parse a raw entry from the API response."""
leaf_input = base64.b64decode(entry_data['leaf_input'])
extra_data = base64.b64decode(entry_data.get('extra_data', ''))
# Parse timestamp (varuint encoding in extra_data)
timestamp = self._parse_timestamp(extra_data)
# Try to parse certificate from leaf_input
certificate = None
try:
if leaf_input[:5] == b'\x93\x01\x00\x00\x00': # TLS certificate opaque
cert_der = leaf_input[5:]
certificate = x509.load_x509_certificate(cert_der)
except Exception:
pass
# Parse SCTs from extra_data
scts = self._parse_scts(extra_data)
return LogEntry(
index=len(entry_data), # Will be set by caller
leaf_input=leaf_input,
extra_data=extra_data,
timestamp=timestamp,
certificate=certificate,
scts=scts
)
@staticmethod
def _parse_timestamp(data: bytes) -> int:
"""Parse varuint timestamp from extra_data."""
if not data:
return 0
# Simple varuint decode (first byte indicates length)
if data[0] < 0x80:
return int.from_bytes(data[:1], 'big')
elif data[0] < 0xc0:
return int.from_bytes(data[:2], 'big')
elif data[0] < 0xe0:
return int.from_bytes(data[:3], 'big')
else:
return int.from_bytes(data[:4], 'big')
@staticmethod
def _parse_scts(data: bytes) -> List[Dict[str, Any]]:
"""Parse SCTs from extra_data."""
scts = []
offset = 0
while offset < len(data):
# SCT list size
if offset + 2 > len(data):
break
list_size = int.from_bytes(data[offset:offset+2], 'big')
offset += 2
for _ in range(list_size):
sct = {}
# Version
version = data[offset]
sct['version'] = version
offset += 1
if version == 0: # v1 SCT
# Log ID (32 bytes)
sct['log_id'] = base64.b64encode(data[offset:offset+32]).decode()
offset += 32
# Timestamp (8 bytes)
sct['timestamp'] = int.from_bytes(data[offset:offset+8], 'big')
offset += 8
# Extensions length
ext_len = int.from_bytes(data[offset:offset+2], 'big')
offset += 2
sct['extensions'] = data[offset:offset+ext_len]
offset += ext_len
# Signature
sig_len = int.from_bytes(data[offset:offset+2], 'big')
offset += 2
sct['signature'] = base64.b64encode(data[offset:offset+sig_len]).decode()
offset += sig_len
scts.append(sct)
return scts
class CTLogMonitor:
"""
Enterprise CT log monitor for anomaly detection.
Usage:
monitor = CTLogMonitor(
log_urls=['https://ct.googleapis.com/logs/argon2024'],
domain_whitelist=['example.com', '*.example.com'],
ca_whitelist=[
'DigiCert Inc',
'Let\'s Encrypt',
'Sectigo Limited'
],
max_cert_lifetime_days=397
)
monitor.run()
"""
def __init__(
self,
log_urls: List[str],
domain_whitelist: Optional[List[str]] = None,
ca_whitelist: Optional[List[str]] = None,
max_cert_lifetime_days: int = 397,
alert_hooks: Optional[List[Any]] = None
):
self.log_urls = log_urls
self.domains = self._parse_domain_whitelist(domain_whitelist or [])
self.ca_list = ca_whitelist or []
self.max_lifetime_days = max_cert_lifetime_days
self.alerts = []
self.alert_hooks = alert_hooks or []
self.clients = [CTLogClient(url) for url in log_urls]
self.last_synced_size: Dict[str, int] = {}
def _parse_domain_whitelist(self, domains: List[str]) -> set:
"""Parse domain whitelist, handling wildcard patterns."""
parsed = set()
for d in domains:
parsed.add(d.lower())
if d.startswith('*.'):
# Add base domain for wildcard matching
parsed.add(d[2:])
return parsed
def _is_domain_owned(self, domain: str) -> bool:
"""Check if domain is in the whitelist."""
domain = domain.lower()
for owned in self.domains:
if domain == owned or domain.endswith('.' + owned):
return True
return False
def _check_certificate(self, cert: x509.Certificate, entry_index: int) -> List[Dict[str, Any]]:
"""
Check a single certificate for policy violations.
Returns list of alert dictionaries.
"""
alerts = []
# Extract subject fields
subject = cert.subject
issuer = cert.issuer
# Get SANs
try:
san_ext = cert.extensions.get_extension_for_class(
x509.SubjectAlternativeName
)
sans = san_ext.value.get_values_for_type(x509.DNSName)
except x509.ExtensionNotFound:
sans = []
# Check 1: Unauthorized domain issuance
unauthorized = [d for d in sans if not self._is_domain_owned(d)]
if unauthorized:
alerts.append({
'type': 'UNAUTHORIZED_DOMAIN',
'severity': 'HIGH',
'entry_index': entry_index,
'domains': unauthorized,
'subject': str(subject),
'issuer': str(issuer),
'not_before': cert.not_valid_before_utc.isoformat(),
'not_after': cert.not_valid_after_utc.isoformat()
})
# Check 2: CA whitelist violation
if self.ca_list:
issuer_name = str(issuer).replace('/CN=', '').split(',')[0]
if issuer_name not in self.ca_list:
alerts.append({
'type': 'UNAUTHORIZED_CA',
'severity': 'HIGH',
'entry_index': entry_index,
'issuer': issuer_name,
'subject': str(subject)
})
# Check 3: Certificate lifetime violation
lifetime_days = (cert.not_valid_after_utc - cert.not_valid_before_utc).days
if lifetime_days > self.max_lifetime_days:
alerts.append({
'type': 'EXCESSIVE_LIFETIME',
'severity': 'MEDIUM',
'entry_index': entry_index,
'lifetime_days': lifetime_days,
'max_allowed': self.max_cert_lifetime_days,
'subject': str(subject)
})
# Check 4: Missing SCTs (if we have entry data)
# This is checked at the log level, not per-certificate
return alerts
def _verify_merkle_proof(
self,
entry_hash: bytes,
proof: MerkleProof,
sth: LogState
) -> bool:
"""
Verify a Merkle tree inclusion proof.
Returns True if proof is valid.
"""
if len(proof.node_hashes) == 0:
# Leaf is root (single entry log)
return hashlib.sha256(entry_hash).digest() == sth.root_hash
# Compute leaf hash
current_hash = hashlib.sha256(entry_hash).digest()
position = proof.leaf_hash # Not used directly, position tracked by index
# Walk up the tree
for i, node_hash in enumerate(proof.node_hashes):
# Determine if current node is left or right child
if (position >> i) & 1:
# Current is left child
combined = node_hash + current_hash
else:
# Current is right child
combined = current_hash + node_hash
current_hash = hashlib.sha256(combined).digest()
return current_hash == sth.root_hash
def sync_log(self, client: CTLogClient, last_size: int = 0) -> List[Dict[str, Any]]:
"""
Sync a single CT log and check for new entries.
Returns list of alerts found.
"""
all_alerts = []
try:
sth = client.get_sth()
except requests.RequestException as e:
print(f'[ERROR] Failed to get STH from {client.base_url}: {e}')
return all_alerts
current_size = sth.tree_size
new_entries = current_size - last_size
if new_entries <= 0:
return all_alerts
print(f'[INFO] Syncing {new_entries} new entries from {client.base_url}')
try:
entries = client.get_entries(last_size, current_size - 1)
except requests.RequestException as e:
print(f'[ERROR] Failed to fetch entries: {e}')
return all_alerts
for i, entry in enumerate(entries):
entry.index = last_size + i
if entry.certificate:
alerts = self._check_certificate(entry.certificate, entry.index)
all_alerts.extend(alerts)
for alert in alerts:
print(f"[ALERT] {alert['type']}: {alert.get('domains', alert.get('subject', 'Unknown'))}")
self.last_synced_size[client.base_url] = current_size
return all_alerts
def run_once(self) -> List[Dict[str, Any]]:
"""Run a single monitoring cycle across all configured logs."""
all_alerts = []
for client in self.clients:
last_size = self.last_synced_size.get(client.base_url, 0)
alerts = self.sync_log(client, last_size)
all_alerts.extend(alerts)
# Trigger alert hooks
for hook in self.alert_hooks:
try:
hook(all_alerts)
except Exception as e:
print(f'[WARN] Alert hook failed: {e}')
return all_alerts
def run_continuous(self, interval_seconds: int = 300):
"""
Run monitoring continuously at specified interval.
Args:
interval_seconds: Seconds between monitoring cycles (default: 5 minutes)
"""
print(f'[INFO] Starting continuous CT monitoring (interval: {interval_seconds}s)')
print(f'[INFO] Tracking logs: {[c.base_url for c in self.clients]}')
print(f'[INFO] Monitoring domains: {self.domains}')
while True:
try:
alerts = self.run_once()
if alerts:
print(f'[ALERT] {len(alerts)} anomalies detected')
else:
print('[OK] No anomalies detected')
except Exception as e:
print(f'[ERROR] Monitoring cycle failed: {e}')
time.sleep(interval_seconds)
def main():
"""Example usage of CTLogMonitor."""
import argparse
parser = argparse.ArgumentParser(description='CT Log Monitor')
parser.add_argument('--domain', action='append', help='Domain to monitor (can be repeated)')
parser.add_argument('--ca', action='append', help='Authorized CA (can be repeated)')
parser.add_argument('--max-lifetime', type=int, default=397, help='Max certificate lifetime in days')
parser.add_argument('--interval', type=int, default=300, help='Monitoring interval in seconds')
parser.add_argument('--once', action='store_true', help='Run once and exit')
parser.add_argument('--logs', nargs='*', default=[
'https://ct.googleapis.com/logs/argon2024',
'https://ct.sectigo.com/logs/mercure2024',
'https://ct.cloudflare.com/logs/nimbus2024'
], help='CT log URLs to monitor')
args = parser.parse_args()
# Configure monitor
monitor = CTLogMonitor(
log_urls=args.logs,
domain_whitelist=args.domain or [],
ca_whitelist=args.ca or [],
max_cert_lifetime_days=args.max_lifetime
)
if args.once:
alerts = monitor.run_once()
if alerts:
print(f'\n[RESULT] {len(alerts)} alerts:')
for a in alerts:
print(f' - {a["type"]}: {json.dumps(a, indent=2, default=str)}')
exit(1)
else:
print('[RESULT] No anomalies detected')
exit(0)
else:
monitor.run_continuous(args.interval)
if __name__ == '__main__':
main()使用示例
#### 单次扫描模式
# 监控指定域名的CT日志
python3 ct_log_monitor.py \
--domain example.com \
--domain "*.example.com" \
--ca "DigiCert Inc" \
--ca "Let's Encrypt" \
--once
# 输出示例:
# [INFO] Syncing 1523 new entries from https://ct.googleapis.com/logs/argon2024
# [ALERT] UNAUTHORIZED_DOMAIN: ['evil.example.com']
# [ALERT] EXCESSIVE_LIFETIME: lifetime_days=730, max_allowed=397
# [RESULT] 2 alerts#### 持续监控模式
# 每5分钟检查一次,持续运行
python3 ct_log_monitor.py \
--domain yourcompany.com \
--interval 300#### 自定义日志源
# 监控私有CT日志(内部部署)
python3 ct_log_monitor.py \
--domain internal.example.com \
--logs https://ct-internal.example.com/logs/private2024 \
--once告警管道集成
Webhook通知
将CT监控与现有告警系统集成:
# 示例:Slack Webhook告警
import urllib.request
import json
def slack_alert(alerts: List[Dict]):
if not alerts:
return
# Format alert message
lines = ['⚠️ CT日志异常检测', '']
for alert in alerts:
lines.append(f'**{alert["type"]}** (严重: {alert["severity"]})')
if 'domains' in alert:
lines.append(f' 域名: {", ".join(alert["domains"])}')
if 'issuer' in alert:
lines.append(f' 签发机构: {alert["issuer"]}')
lines.append('')
payload = json.dumps({'text': '\n'.join(lines)}).encode('utf-8')
req = urllib.request.Request(
'https://hooks.slack.com/services/YOUR/WEBHOOK/URL',
data=payload,
headers={'Content-Type': 'application/json'}
)
urllib.request.urlopen(req)
# 注册告警钩子
monitor = CTLogMonitor(
log_urls=['https://ct.googleapis.com/logs/argon2024'],
domain_whitelist=['example.com'],
alert_hooks=[slack_alert]
)告警阈值策略
| 场景 | 告警级别 | 响应时间 | 处理方式 |
|---|---|---|---|
| 未授权域名签发 | P1 | 15分钟 | 立即联系CA撤销,更新监控清单 |
| 超期证书签发 | P2 | 1小时 | 记录审计,评估合规影响 |
| CA违规签发 | P1 | 15分钟 | 立即暂停该CA的证书申请 |
| 重复域名检查 | P3 | 24小时 | 批量报告,定期清理 |
性能优化与生产部署
增量同步策略
CT日志持续增长,全量扫描成本过高。采用增量同步策略:
- 启动时:获取当前日志树大小(STH),记录为基准
- 后续检查:仅获取
last_size到current_size之间的新条目 - 断点续传:本地持久化
last_size,服务重启后继续
# 持久化同步状态
import sqlite3
def save_sync_state(log_url: str, tree_size: int, db_path: str = 'ct_monitor.db'):
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute('''
INSERT INTO sync_state (log_url, tree_size, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(log_url) DO UPDATE SET tree_size = excluded.tree_size
''')
conn.commit()
conn.close()
def load_sync_state(log_url: str, db_path: str = 'ct_monitor.db') -> int:
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute('SELECT tree_size FROM sync_state WHERE log_url = ?', (log_url,))
row = c.fetchone()
conn.close()
return row[0] if row else 0批量并行查询
多日志源可并行查询:
from concurrent.futures import ThreadPoolExecutor, as_completed
def sync_all_logs_parallel(self) -> List[Dict]:
"""Parallel sync across all configured logs."""
all_alerts = []
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(self.sync_log, client, self.last_synced_size.get(client.base_url, 0)): client
for client in self.clients
}
for future in as_completed(futures):
client = futures[future]
try:
alerts = future.result()
all_alerts.extend(alerts)
except Exception as e:
print(f'[ERROR] Log sync failed for {client.base_url}: {e}')
return all_alerts资源消耗估算
| 日志规模 | 新增条目/日 | 内存占用 | CPU占用 | 网络流量 |
|---|---|---|---|---|
| 小规模(<10万) | 1,000 | 50MB | 低 | 10MB |
| 中规模(10-100万) | 10,000 | 200MB | 中 | 100MB |
| 大规模(>100万) | 100,000 | 500MB | 高 | 1GB |
合规审计输出
生成审计报告
def generate_audit_report(alerts: List[Dict], period: str = 'daily') -> Dict:
"""Generate structured audit report."""
report = {
'generated_at': datetime.now(timezone.utc).isoformat(),
'period': period,
'summary': {
'total_alerts': len(alerts),
'high_severity': sum(1 for a in alerts if a['severity'] == 'HIGH'),
'medium_severity': sum(1 for a in alerts if a['severity'] == 'MEDIUM'),
'low_severity': sum(1 for a in alerts if a['severity'] == 'LOW'),
},
'alerts': alerts,
'compliance_status': 'PASS' if not alerts else 'FAIL'
}
return report审计合规要点
根据CAB Forum BR第1.6节,企业CT审计应包含:
- 覆盖率检查:所有应提交证书均已提交到CT日志
- 时效性检查:证书签发后24小时内完成日志提交
- SCT有效性:证书包含有效且未过期的SCT
- 异常检测:已配置的监控规则产生预期告警
已知限制与注意事项
- 日志隐私:公共CT日志可见所有提交内容,包括证书主体信息。敏感证书建议使用私有CT日志。
- 日志选择性订阅:RFC 6962允许CA选择提交到哪些日志,但不保证提交到所有日志。企业应监控主流日志(Google、Sectigo、Cloudflare)。
- 性能权衡:实时全量扫描成本较高,建议针对自有域名做增量监控,而非全量日志扫描。
- RFC 9162更新:新版RFC 9162(2022年)引入了Mandatory Let's Encrypt和STH Refresh机制,建议在长期运行的监控系统中考虑兼容性。
- 私有CT日志:企业内部可部署私有CT日志(如基于ct-panic或transparency-go),用于审计内部CA签发行为。
国密CT日志合规要求
CT日志监控在国密PKI体系中有特殊的合规要求,主要涉及以下标准:
GM/T 0015-2023 证书格式要求
GM/T 0015-2023《数字证书格式》规定了国密SM2证书的二进制格式。SM2证书在CT日志中提交时,需确保:
- 证书签名算法标识:使用SM2-with-SM3(OID: 1.2.156.10197.1.401),而非ECDSA与SHA-256的组合
- 公钥编码格式:SM2公钥采用GM/T 0009-2012定义的未压缩格式(04前缀 + X/Y坐标各32字节)
- 扩展字段兼容性:国密证书可能包含非标准扩展项,CT日志解析时需兼容处理
GM/T 0054-2018 密评要求
根据GM/T 0054-2018《信息系统密码应用基本要求》,三级及以上系统需满足:
| 合规要求 | CT日志监控对应措施 |
|---|---|
| 密钥安全 | 监控SM2密钥是否被滥用签发证书 |
| 通信安全 | 验证TLCP协议下证书的CT提交合规性 |
| 安全审计 | CT日志作为客观审计源,留存不少于3年 |
国密CT日志实践要点
- 私有CT日志部署:国密场景下可部署基于transparency-go的私有CT日志,仅接受SM2证书提交
- 双日志监控:同时监控公共CT日志(Google/Sectigo/Cloudflare)和私有CT日志
- SM2证书特殊校验:除常规SCT验证外,需额外验证证书是否符合GM/T 0015-2023格式
总结
CT日志监控是企业PKI运营的必备能力,而非可选功能。本文提供了:
- RFC 6962兼容的CT日志查询器完整实现
- 六类异常检测算法与阈值策略
- 生产级性能优化方案(增量同步、并行查询、状态持久化)
- 合规审计报告生成模板
- 最小化延迟:建议监控间隔 ≤5分钟,告警响应 ≤15分钟
- 多日志源:至少监控Google、Sectigo、Cloudflare三个公共日志
- 域名白名单:精确配置监控域名,避免误报
- 自动化闭环:告警 → 通知 → 处置 → 审计,形成完整流程