1. 项目概述为什么要做这样一个侦察工具先聊一个场景你负责某个业务系统的安全手里有十几个域名但真正对外暴露的资产可能远比你想的多。某个子域名是两年前临时拉的测试环境早已被人遗忘却一直挂在公网上某个新业务上线时顺手申请的域名只配了证书没做任何防护。这些“影子资产”恰恰是攻击者最喜欢下手的地方——因为没人维护、没人监控。传统的做法是什么翻内部台账、问各个业务负责人、登录云控制台一个个看。这套流程不是不行而是太慢、太依赖人的记忆和文档的完整度。那有没有一种方式能从公开的数据源里主动发现某个域名“家族”的全貌答案是肯定的——证书透明度日志Certificate Transparency, CT。这个项目的核心就是利用CT日志这个公开数据源通过Python抓取证书信息再借Streamlit搭建一个交互式可视化面板实现对域名资产的自动盘点、归属分析、风险初筛。整个过程不依赖任何商业情报源也不需要在目标系统上安装任何探针属于典型的被动侦察思路。我把这个工具定位为“侦察辅助”它解决的是三个问题我到底有哪些公开暴露的域名和子域名哪些是台账里没有的哪些证书快过期了、哪些证书来路不明、哪些子域名存在明显的敏感关键字如何让这些数据可视化让不懂技术的业务同事也能看懂这套东西适合谁用搞安全运营的、做渗透测试前期的、负责云上资产管理的甚至做DevOps的同学想梳理微服务域名拓扑的都可以直接拿这套思路去改。下文我会从设计思路、数据采集、清洗归并、可视化实现、问题排查一路讲下来把完整实现路径摊开聊。2. 整体设计思路为什么选CT日志和Streamlit2.1 CT日志机制与侦察价值在CT日志出现之前证书颁发机构CA签发证书的过程几乎是黑盒的安全研究人员想获取某个域名的证书列表只能靠运气或商业数据。2013年之后主流浏览器和CA开始推行CT日志机制每一张公开信任的SSL/TLS证书在签发时必须提交到至少两个公开日志服务器日志条目一旦写入就无法篡改和删除。这意味着什么意味着只要有人给你的域名签发过证书这个痕迹就永久挂在公开日志里。对防御方来说这是发现历史遗留资产的金矿对攻击者来说这也是枚举子域名的高效渠道。CT日志天然具有“无法伪造、无法删除、完全公开”三个特点所以用它来摸底资产比扫描器爆破子域名要快得多也比翻内部台账要全得多。举个例子你输入域名 example.comCT日志里不光会返回 www.example.com 和 api.example.com还可能翻出 staging.example.com、jenkins.example.com、prod-backup.example.com 这类只有开发阶段才存在的子域名。这些都是扫描器不一定扫得出来、台账里大概率没记录的资产。2.2 crt.sh查询接口的价值目前查询CT日志最方便、最稳定的公共接口就是crt.sh。它本质是一个基于PostgreSQL的CT日志聚合查询库支持按域名、按证书ID、按SHA-256指纹等多种方式检索。对开发者来说它不需要申请API Key不需要注册账号直接请求即可。crt.sh的查询URL很简单核心接口可以这样理解请求域名时返回用JSON格式的证书信息列表包含证书的序列号、颁发者、签发时间、到期时间、SAN列表即该证书覆盖的所有域名等关键字段。老实说这几个字段就够做大部分资产分析工作了。选择crt.sh的另一个考虑是数据覆盖面广。市面上其他CT查询服务往往只覆盖部分日志池而crt.sh基本收录了主流日志服务器的数据跨平台证书也能查得到。对于资产侦察这种对完整性有要求的场景覆盖面就是硬指标。2.3 Streamlit为什么适合做这类面板可视化面板的选择其实纠结过。最初我考虑过Flask ECharts、Grafana Prometheus甚至用Jupyter Notebook来交互。但最终选了Streamlit理由比较实在第一纯Python生态。项目本身的数据逻辑全部用Python处理如果前端再用一堆JavaScript和后端API来回调开发工作量至少翻一倍。Streamlit允许我用同一门语言搞定逻辑和界面省去了前后端联调的时间和沟通成本。第二交互状态管理简单。Streamlit的会话机制是“按钮-回调-重跑脚本”不需要像传统Web开发那样自己去维护前端状态。用户选一个域名、点一个按钮后端重新执行一遍取数和渲染虽然性能上不如前端缓存方案但对这种中小规模的查询工具来说完全够用。第三部署极其方便。Streamlit应用本质上一个Python进程服务器上装好依赖、写好配置一条命令就能拉起来也不需要配Nginx反向代理做静态资源处理。如果需要给别人临时演示用内网穿透或云服务器映射端口就行。架构上这个项目分三层数据采集层负责请求crt.sh和解析响应清洗归并层负责去重、过滤、归一化展示层负责查询交互和可视化渲染。每层职责单一换数据源也好换展示端也好都不会影响其他模块。这种分层的价值在后续扩展时会越来越明显。3. 数据采集层实现CT日志接入与解析3.1 请求crt.sh接口的两种方式crt.sh提供两种主要查询方式我在项目里都实现了供不同场景切换方式一网页HTML接口直接请求 crt.sh/?q域名返回的是HTML页面需要用解析器解析表格内容。这种方式的好处是不需要额外处理嵌套的JSON结构坏处是页面结构变动过几次维护形HTML的解析规则比较脆弱。方式二JSON接口请求 crt.sh/?q域名outputjson返回标准JSON数组每一条记录对应一张证书的基本信息。项目主体采用这种格式因为字段结构稳定、解析快、后续处理也方便。这里有个细节crt.sh的JSON接口一次性返回的数据可能很多某些大型域名返回几千条记录很常见。所以请求时最好设置合理的超时时间。我实践中采用的方案是15秒连接等待、30秒读取超时如果超过说明查询过大或接口繁忙再配合重试机制。核心请求代码如下import requests import time def fetch_ct_logs(domain, retries3, timeout30): base_url https://crt.sh/ params {q: domain, output: json} headers {User-Agent: Asset-Recon-Tool/1.0} for attempt in range(retries): try: resp requests.get( base_url, paramsparams, headersheaders, timeouttimeout ) resp.raise_for_status() return resp.json() except requests.exceptions.Timeout: print(f[警告] 第 {attempt 1} 次请求超时正在重试...) time.sleep(3 * (attempt 1)) except requests.exceptions.JSONDecodeError: # crt.sh偶尔会返回空页面或HTML错误页 print(f[警告] 第 {attempt 1} 次返回非JSON格式正在重试...) time.sleep(5) return []几个经验之谈User-Agent必须带。不带UA的请求很容易被CDN层拦截加了UA后成功率明显提升。重试时间要用指数退避否则接口繁忙时连续重试反而会加剧问题。crt.sh偶尔会因为后端数据库负载过高返回500或503这种时候错误信息不是JSON请求库会抛JSON解析异常需要额外处理。3.2 JSON字段解读与数据建模拿到crt.sh返回的JSON之后每条记录大致包含这些字段字段说明用途分析issuer_name证书颁发者名称判断是否为公开CA签发发现自签证书common_name证书主域名资产识别基础字段name_valueSAN列表可能包含多个域名子域名枚举的核心来源not_before证书生效时间判断资产生命周期not_after证书过期时间风险预警的关键指标serial_number证书序列号关联同批次的证书变更记录idcrt.sh内部证书ID可拼接出证书详情链接我最常用的字段是name_value因为一张证书往往覆盖多个子域名比如一张通配符证书可能同时包含 *.example.com 和 example.com也可能在SAN里带上多个非通配符域名。解析时需要按换行符或逗号拆分再逐条清洗。数据建模阶段我简单定义了两个数据结构原始证书记录类和归一化域名记录类。归一化域名记录会过滤掉通配符前缀、统一成小写、去掉前后空格同时记录其来源证书ID和签发时间这样后续在面板上展示“这个域名是从哪张证书里翻出来的”时能直接回溯线索。归一化逻辑示例import re def normalize_domain(raw_value): raw_value raw_value.strip().lower() # 去除通配符前缀 if raw_value.startswith(*.): raw_value raw_value[2:] # 去掉证书内容里常见的HTML转义 raw_value raw_value.replace(\\n, ).strip() return raw_value3.3 排查数据异常的兜底逻辑实际跑过之后发现crt.sh返回的JSON数据有好几种“惊喜”情况第一种返回的数据不是数组而是字典通常是接口报错信息。这种情况搜索引擎都遇到过直接判断类型并抛给上层处理即可。第二种某条记录里name_value是None或空字符串。这种大概率是历史数据不完整直接跳过即可否则后续split会报错。第三种响应内容超大导致内存占用过高。某些顶级域名的证书日志动辄上千条全部塞进DataFrame会导致后续操作变慢。为了压缩内存我只保留需要的四五个字段其余字段直接丢弃。这招对性能提升非常明显。第四个值得注意的点crt.sh服务器时间是UTC返回的时间字段是“年-月-日”格式在后续判断“证书还有几天过期”时要统一基于当前UTC时间计算避免时区差导致误判。4. 数据清洗与资产归并从证书记录到资产清单4.1 为什么清洗环节必不可少把crt.sh的原始数据直接丢到可视化面板上除非你只是为了看个新鲜否则基本没法用。原因很简单证书记录不等于域名资产。一张证书可能覆盖多个域名一个域名也可能出现在多张证书里通配符证书会引入大量重复子域同一公司的多个业务域名混在一起没法按归属分开。清洗归并的目标是回答三个问题去重后到底有多少独立域名哪些域名是“核心资产”哪些只是“相关但不重要”如何按业务线或负责人快速归类我按数仓ETL的思路设计了这一步清洗去掉无效数据、去重按域名合并、归并按规则分组。三条步骤层层递进最后的输出是干净的资产清单表。4.2 通配符展开与去重策略CT日志里最坑的一步是通配符证书。比如某张证书的SAN是“.example.com”和“.api.example.com”如果只是简单地去掉通配符前缀存成“example.com”会丢失“api.example.com”这个二级域名信息如果完全忽略这个字段又会漏掉很多子域名。我的处理策略是这样通配符记录代表的是“该层级下的任意子域名”。虽然它不能告诉我们具体存在哪些子域名但它本身就是一个重要的资产线索——相当于提示你这片网段被分配给某个业务了。所以对于通配符证书我采取的归一化思路是保留通配符标记但去掉星号前缀在后续面板中单独用“泛域名资产”维度展示。去重逻辑相对简单以归一化后的域名为键去重时把来源证书ID合并到一个列表里。如果同一域名出现了多次说明它被多次签发或续期这也是风险信号之一——一个频繁更换证书的域名往往意味着运维流程不稳定。去重示例def deduplicate_domains(records): domain_map {} for rec in records: domain rec[domain] if domain not in domain_map: domain_map[domain] { domain: domain, sources: [], first_seen: rec[not_before], last_seen: rec[not_after], } domain_map[domain][sources].append(rec[cert_id]) return list(domain_map.values())这里有一个反向经验想分享不要一上来就全局去重。如果是给多个业务域名做资产盘点去重顺序应该是先按业务域名分组——组内去重——再跨组合并发现业务间共用的公共域名比如统一的SSO子域。这样能保留业务归属信息后续按负责人分发风险报告时用得上。4.3 淘汰与归并规则设计清洗归并阶段我再定了几个淘汰规则排除邮箱反查类记录。CT日志支持按邮箱查询某些证书的SAN里会带邮箱域名这种数据对资产盘点没有意义直接丢弃。排除明显无关的根域名。查example.com时返回记录可能包含带www.example.com的上级或跨域记录需要按后缀精确匹配只保留目标域名及其子域。排除IP地址和URL前缀。有少数证书的SAN字段会混入IP或带协议头的地址统一用正则剔除。归并环节我实现了一个简单的高危关键字策略把域名拆成“子域前缀主域”两段对子域前缀做关键字匹配。命中以下关键词的域名自动打标签关键字类别示例关键词风险含义环境类test、dev、staging、uat可能是非生产环境防护薄弱管理类admin、console、dashboard管理后台类系统重点攻击目标集成类jenkins、gitlab、ci、docker开发基础设施泄露源码风险高数据类db、mysql、es、mongo数据库或数据服务平台内部系统vpn、mail、oa、erp企业内网应用暴露到公网这套关键词规则不必做得太重先按表匹配打标签后续再根据业务反馈持续补充。比如我维护了统一的标签配置表用户在前端页面就能动态增删关键词不用改后端代码。4.4 保存中间结果的必要性操作上有一个之前踩过的坑清洗归并的过程不能只存在内存里。因为Streamlit每次点击交互都会重新执行整个脚本如果每次都要重新请求crt.sh再清洗归并页面响应会慢到让用户怀疑人生。解决方案很简单第一次抓到数据后把归一化域名清单和原始证书记录分别保存为本地文件我用CSV和JSON两种格式后续面板查询时优先读本地缓存。设置缓存过期时间比如24小时过期后重新拉取。Streamlit提供的st.cache_data装饰器能自动处理这套逻辑极大省心。5. 可视化面板实现Streamlit的交互式设计5.1 面板整体布局与导航结构可视化面板分了四个页签导航用Streamlit的侧边栏radio组件实现页签名称核心功能面向用户资产总览展示资产数量、风险分布、顶级域名列表管理层、运营人员域名检索按关键字搜索域名查看完整资产详情安全工程师证书体检按证书维度查看到期情况和颁发机构运维、安全工程师高危筛选按标签筛选高风险资产一键导出安全负责人这种按角色拆页签的设计比单页堆图表要好用得多。管理层面只看总览数字安全工程师深入检索运维关注证书到期各取所需。我一直认为工具的面板设计本质是产品设计你要先想清楚给谁看再决定画板内容。5.2 资产统计卡片与图表配置总览页的第一屏是四张统计卡片独立域名总数、泛域名证书数、近30天新增域名、即将到期证书数90天内。Streamlit里做卡片没有现成组件我用st.metric加st.columns组合实现视觉上简洁信息密度也够。第二屏放两张核心图第一张是“域名层级分布图”。把归一化域名拆成主域和各级子域按二级域名分组统计数量展示方式用柱状图x轴是二级域名y轴是对应子域数量。这样能直观体现哪些业务线资产面最大。第二张是“证书到期时间热力图”。用日历热力图的方式横轴是未来12个月纵轴按证书计数着色红色越深表示那个月到期证书越多。到期集中时可能存在批量续期问题也更容易被运维团队遗漏。图表库我用的是Plotly。Streamlit原生图表虽然够用但Plotly提供悬浮交互和缩放手势对于查看几十上百个点的数据分布时体验更好。图表实现示例import plotly.express as px import streamlit as st fig px.bar( domain_summary_df, xsecond_level_domain, ysubdomain_count, colorsubdomain_count, title各二级域名下的子域数量分布, ) fig.update_layout(xaxis_tickangle-45, height450) st.plotly_chart(fig, use_container_widthTrue)5.3 域名检索与详情下钻域名检索页我提供了两个入口按关键字模糊搜索和按标签精确筛选。模糊搜索支持一次性输入多个关键字用空格分隔实现“并且”的过滤逻辑。标签筛选则用st.multiselect选定高危、环境类、管理类等标签后自动过滤。检索结果用st.dataframe展示表格列包含域名、所属主域、标签、首次发现时间、最近到期时间。每条域名提供“查看来源证书”按钮——点击后跳转到该域名的证书详情列表看是哪家CA签发的、SAN里面还有其他什么域名、证书指纹是什么。这个下钻能力在做溯源时特别有用也能帮安全人员判断某个子域名的归属团队。下钻数据我存了一份按证书维度的DataFrame检索时先过滤域名表拿到关联证书ID列表再用证书表做join。逻辑不复杂关键是保证两张表的主外键对得上——这一步在清洗归并阶段就要设计好。5.4 导出与共享让数据流动起来可视化面板如果只停留在“看得到”那就是花架子。我增加了两个出口第一个是数据导出。表格右上角提供CSV导出按钮下载内容直接可用Excel打开。高危筛选结果也可以单独导出字段格式已经是风险评估报告所需的维度包括域名、风险标签、证书剩余天数、签发机构。第二个是分享链接。用Streamlit的st.query_params把当前筛选项编码进URL用户复制URL发给同事对方打开后页面自动应用相同筛选条件。这个功能在团队协作时非常实用省去了“我看到了这几个域名你打开面板自己筛一下”这种低效沟通。6. 工具选型解析与性能优化6.1 依赖组件与版本选择整个项目的依赖清单刻意保持精简核心四个库streamlitWeb框架。当前在用1.28以上版本早期版本对st.query_params支持不完善。requests处理HTTP请求。用标准库的urllib也可以但requests处理超时和重试更方便。pandas数据处理。所有清洗归并逻辑都基于DataFrame实现。plotly交互图表渲染。如果你还需要域名正则校验建议引入tldextract来拆分主域和子域比自己写正则省心得多。Python的“re”模块拆域名容易在复杂后缀上翻车tldextract内置了公共后缀列表准确率基本接近满分。项目里我把tldextract用作域名归组的底层逻辑稳。6.2 缓存策略与响应速度优化Streamlit应用最大的性能陷阱就是每次交互都重跑脚本如果脚本里有重量级操作比如拉取crt.sh体验会差到让人崩溃。这个问题我用三层缓存方案解决第一层本地文件缓存。每次拉取完crt.sh数据后保存到data/目录下的JSON文件。面板启动时先检查文件是否存在且未过期如果未过期直接加载避免启动阶段就触发大规模请求。第二层st.cache_data装饰器。对于从本地文件加载、清洗、归并这些计算密集且结果不变的操作加上缓存装饰器Streamlit会自动把结果存在内存里。注意使用cache_data装饰器时函数参数必须是可哈希的比如字符串、整数不能直接把DataFrame当参数传进去。第三层st.session_state。跨页面跳跃时保留临时状态比如用户已经选好的筛选条件、当前浏览的页面索引。用session_state还有一个好处是可以做手动刷新按钮——“强制重新拉取CT日志”这个动作就绑定在按钮上。缓存策略示例st.cache_data(ttl3600, show_spinnerFalse) def load_domain_data(cache_key): # cache_key是业务域名字符串用于区分不同查询的缓存放 df read_cleaned_domains() return df6.3 部署方案与监控小技巧Streamlit应用的部署很简单服务器上安装依赖后用streamlit run app.py --server.port 8501即可。但我建议在部署时注意三个细节一是用systemd守护进程托管。这样进程意外死了会自动重启日志也会写到journald里方便排查。二是不要裸奔HTTP。在安全要求高的环境里我会套一层反代用HTTPS对外提供服务。三是给Streamlit设访问密码。Streamlit自带简单的密码鉴权功能通过secrets文件配置即可实现至少在公网部署时有一道基础防护。日志方面我会在采集模块里加一个简单的请求耗时统计和失败率记录。不引入额外日志框架直接用Python内置的logging模块输出到文件。观察一段时间后如果发现crt.sh接口超时率超过15%就调整重试参数和缓存过期时间保证面板在接口不稳定时依然能靠缓存正常工作。7. 常见问题与排查技巧实录7.1 crt.sh接口超时与限流的应对手段实际使用中crt.sh偶发性超时是常事尤其是查询大域名或赶上后端数据库高峰时段。有几次我在演示时页面长时间转圈检查日志发现就是接口没响应。后面的解法分两步第一步是降低请求频次。给每次查询之间加延时避免快速连续点击触发接口的限流策略。用户点击查询按钮后我写了一个“正在查询”的占位状态把按钮置灰几秒从源头减少并发请求。第二步是做好数据兜底。把清洗过的结果按域名存入SQLite数据库查询时先读库库中没有再请求网络。这个方案适合在企业内部定期维护数据的情况比如每周跑一次全量扫描中间随时可以查快照。7.2 返回数据为空或格式异常的处理流程有用户反馈说输入主域名查询结果页面空白。排查后发现几种情况域名确实没有任何公开证书新注册未部署SSL的域名。这种情况直接提示“未发现证书记录”。crt.sh对某些特殊域名返回空数组但实际是查询参数编码问题。域名中包含中划线、下划线、国际化域名时需要对查询参数做URL编码。接口返回的不是JSON数组而是JSON对象里面包含错误信息。代码里用isinstance做类型判断如果是字典类型且包含error字段解析错误信息并展示。处理流程如下先判断返回类型再遍历记录做字段校验最后再清洗归并。每一步都有日志输出面板底部留一个“日志预览”区域出问题时可以直接看到错误点在哪一步。7.3 数据误报与漏报的平衡CT日志侦察的一个天然局限是日志记录的是“证书曾经存在过”而不是“域名当前可用”。一张证书可能签发后从未部署也可能已经吊销。所以面板上需要把“发现时间”和“证书到期时间”都用颜色标注让用户能意识到这是一份历史记录而非实时扫描结果。漏报方面如果攻击者使用不公开的证书私有CA签发CT日志里不会有痕迹。这点需要在工具说明里明确注明CT日志适合做常规资产摸底不适合作为唯一的数据源。建议和其他渠道配合比如DNS历史记录、搜索引擎索引、代码托管平台搜索能拼出一张更完整的资产地图。7.4 域名解析验证的补充方案之前有个需求是区分“存活子域名”和“已不存在的子域名”。CT日志告诉我们域名曾经存在证书但你现在去访问大概率有很多已经解析不了。于是我在工具里加了一个可选的“存活性探测”模块对去重后的域名列表做批量DNS解析记录解析成功、解析失败、解析到内网IP三种状态。这个模块默认关闭需要用户在设置页手动开启因为批量解析几千个域名还是会花一些时间的。解析结果会回填到资产表中增加“存活性”一列。这样安全人员可以一键筛选出“有证书且当前解析正常”的域名这些才是真正暴露在公网上的风险点。我加这个功能的初衷也不是为了追求实时准确度而是至少帮使用者剔除一部分历史遗留域名缩小关注范围。8. 扩展方向与实战心得8.1 从侦察到监控定时任务的接入方式工具做完侦察功能后下一步自然是接入定时任务形成持续监控。做法是把Python代码拆成两部分核心分析函数不依赖Streamlit和UI展示层。核心函数可以直接在命令行下运行用crontab定时跑然后把结果写入数据库或CSV。Streamlit只负责读取结果文件并渲染。这样可以实现的效果是每天凌晨自动拉取一次的CT日志数据生成当天资产快照对比前一天的数据得到新增域名、新增高危标签等变化信息。变化量可以进一步接入告警系统。最开始我实现的逻辑还简单只是写了个文本文件对比脚本后来逐步整理成了标准化的资产差异报告函数。目前的核心差异接口接收昨天的域名集合和今天的域名集合输出新增、消失、标签变化三个列表。写差异对比时要注意一个细节证书续期会导致“last_seen”字段变化但域名本身没变不应算“新增”。所以差异对比一定要先按域名归组再做集合差千万不能按证书条目做对比。否则每次续期都会触发大量“新增资产”告警。8.2 跨数据源扩展的架构预留虽然项目现在只接入了crt.sh但架构上是预留了多数据源扩展口的。比如配置类数据源某平台子域名导出接口、云厂商的CDN域名列表情报类数据源情报社区的子域名情报接口、威胁情报平台的被动DNS接口资源类数据源域名注册局公开的DNS记录、证书吊销列表设计思路是每个数据源实现一个统一的获取与解析函数返回同一种标准的域名记录类。上游只认标准类这样新接入一个源只需要写一个适配器下游的所有清洗和展示逻辑完全不用改。“主域子域来源标签发现时间”这个核心抽象是整套扩展能力的基石。8.3 持续维护的几条心得体会这个工具从第一版到现在的稳定版踩过的坑不少捞几条有价值的经验出来第一数据质量远远比功能花哨重要。早期版本为了炫技加了各种图表和动画交互结果数据不准确用户看两天就弃用了。后来把精力集中到清洗规则和字段准确性上哪怕界面朴素一点数据可靠才是工具存活的生命线。第二CT日志侦察要跟业务负责人多聊。工具产出了一批资产清单不跟业务方核对归属很多域名你都不知道是什么团队建起来的。让业务负责人看一眼清单标一下归属和用途再回填到系统里这个资产库就真正“活”了。第三申请一个控制变量式的功能底线工具的价值不是自动做决策而是辅助人做决策。它告诉你“这个域名有证书、有高危标签、快要到期了”但要不要下线、要不要补监控仍然需要人来判断。保持工具的分析性质不要让它变成没有反馈的自动扫描器更不要让它做出超出数据支撑的断言。最后再分享一个具体的小技巧在清洗规则里把“内容安全”“对象存储”“Web应用防火墙”这类云产品常见域名后缀单独归类不要混到业务子域名里。这样总览页的整体数量会真实很多不会出现“十几个Tomcat测试页域名混在业务资产里”的混乱状态。这个规则是我在梳理某电商项目资产时总结出来的当时有几十个云服务自动生成的域名把风险统计搞得毫无参考价值加了这个分类后整个仪表盘的数字终于说人话了。