
简介《OSINT权威指南尽职调查实务》是面向开源情报初学者与需要更新知识体系的资深从业者的实务型电子书聚焦商业智能场景下的尽职调查流程与方法。全书由Cynthia Hetherington撰写第三版更新至2024年内容覆盖商业调查类型、企业结构梳理、网络数据库与公开信息源检索以及调查结果的分析呈现并延伸到SWOT、供应链与价值链分析、社交媒体及行业特定资源的情报收集。资源包仅含1个PDF文件约6.29MB便于集中阅读与按章节查阅。已有185人学习下载。读者可从中获得从信息源选择、检索路径设计到结果验证与结构化分析的完整框架也能借助书中的实践案例和分析工具理解如何把零散公开信息转化为可用于商业判断的情报依据适合边学边对照实际调查任务使用。1. 一份材料看着都对为什么尽调还是查不出问题OSINT 在尽职调查里不是一个工具名而是一条证据链的组装顺序。前段时间帮一个做硬件集成的团队复核供应商准入材料对方给的东西很齐营业执照、两份合同扫描件、一张官网截图。真正让我停手的是三件小事——官网域名的首次注册时间比主体成立时间晚了四年证书透明度记录里同一张证书还挂着两个从未在材料中出现过的域名其中一个域名的注册邮箱和投标联系人邮箱同域。三条全部来自公开来源全程没有惊动对方。这就是 OSINT 尽调要解决的问题在对方不提供、也不知道你在查的前提下用公开登记信息、基础设施痕迹和公开文本把“他说的”变成“能对上的”。适合做供应商准入、投前复核、渠道商资质核查、合作方背景排查的人——手上有一堆零散材料缺的是一套能重复跑的流程以及一条知道什么时候该停的红线。2. 实体画像怎么拆三层字段表与信号可信度矩阵2.1 主体、关联方、时间线开搜之前先把空格画出来新手做 OSINT 尽调最常见的动作是“先搜了再说”搜完把一堆截图往报告里一贴看起来信息量很大实际上一条都用不上。原因很简单搜索引擎给的是相关性排序尽调要的是字段级的一致性。正确的顺序是先把要填的空格画出来再决定去哪个来源填。我一般把画像拆成三层。主体层回答“是不是这个法人”关联层回答“它还牵着谁”时间线层回答“这些事发生的先后顺序自不自洽”。三层里最容易出成果的是第三层因为时间不会撒谎——主体成立日期、域名首注册时间、证书首次签发时间、商标申请日、公开招标中标日、招聘信息首次出现时间这几条线的先后关系只要错位就一定有一个解释而这个解释往往就是风险点。层级字段主要来源用来判断什么主体层完整注册名称、唯一主体标识注册号、注册地址、成立日期、登记状态、经营范围、法定代表人、注册资本企业登记公开库、官方公告主体是否真实存续、经营范围是否覆盖标的业务关联层股东与对外投资、同地址其他主体、同联系电话或邮箱、共用域名与证书、共用名称服务器登记库、DNS、证书、公开文本是否存在未披露的关联方或空壳结构时间线层成立日、域名首注册日、证书首签发日、商标申请日、中标公告日、招聘信息首次出现日登记库、RDAP、证书透明度、公告平台材料叙事与客观时间是否自洽字段还要再分三类这个分类决定了你该花多少时间A 类是能直接查到的硬字段比如成立日期、登记状态花十分钟就能拿全B 类是能推断但需要两条以上证据的软字段比如实际经营地、真实业务规模C 类是公开渠道根本拿不到的比如真实客户名单、实际控制人的个人资产。新手最容易在 C 类字段上耗掉一整天最后写一句“未能核实”性价比极低。提示C 类字段不是尽调的失败是尽调的边界。把“查不到”明确写进报告比含糊带过安全得多。三层画完之后每个空格旁边其实已经自带了一个“去哪查”的标签检索就变成了按图索骥而不是漫无目的地浏览。这一步多花二十分钟后面能省两小时。2.2 给每条线索定级什么才算一条“事实”尽调报告最容易被打回的地方不是结论下得不够狠而是把“网上看到”直接当成“事实”写。定级规则要先于检索定好否则你会在写报告的时候反复纠结某条信息到底能不能用。我用的来源分级是这样S1 是一手登记信息与官方公告包括企业登记库、法院或行政机关依法公开的公告、招投标平台的中标公示S2 是主体自述包括官网、宣传册、投标文件、新闻通稿S3 是基础设施痕迹包括 DNS 记录、证书、域名注册数据S4 是第三方转载与聚合平台S5 是社交媒体与个人发布。默认可信度依次递减。组合规则比单条来源更重要两条相互独立的 S1 指向同一结论可以定 A 级单条 S1 定 B只有 S2 的一律写成“主体主张”不能进结论段S4 必须回溯到原始出处回溯不到的按 S5 处理。这里有一个血泪经验——“独立”两个字经常被自己骗过去同一篇通稿被十家媒体转载那是十个渠道、一个来源不是十条证据。组合方式定级能不能写进结论两条独立 S1 一致A可以直接陈述单条 S1B可以附来源与采集时间S1 S3 相互印证A-可以注明推断链条仅 S2C不能只能写成“待验证主张”S1 与 S2 冲突冲突项不能必须列为待解释项冲突本身是最有价值的产出。一条 S1 和一条 S2 打架不要急着判断谁对谁错先把它记成“待解释项”然后在后续检索里专门找第三条证据。尽调报告的说服力很大程度上来自“我发现了矛盾并且给出了处理方式”而不是“我查到很多东西”。还有一个容易被忽略的纪律时间戳。域名注册数据会变页面会改公告会下架。每条记录都要带采集时间和原始地址能用哈希就用哈希能存本地快照就存快照。没有采集时间的证据在复核环节等同于没有。3. 域名与证书用 RDAP 和证书透明度把主体链路接起来3.1 一条命令捞出证书里的全部域名基础设施痕迹之所以好用是因为它有一个绕不开的约束证书是为了让人能访问而签发的只要站点在提供服务证书就必须公开可查而且很难长期藏住。一张多域名证书经常一次性暴露同一主体持有的其他站点这是尽调里性价比最高的一类线索。取证书不需要任何商业平台一条命令就够# HOST 换成待查域名只读证书元数据不校验证书链对方证书可能已过期或自签 HOSTtarget-domain.tld openssl s_client -connect ${HOST}:443 -servername ${HOST} -showcerts /dev/null 2/dev/null \ | openssl x509 -noout -subject -issuer -dates -fingerprint -sha256 -ext subjectAltName逻辑说明s_client完成一次 TLS 握手并把服务端证书链打印到标准输出x509接住这串 PEM 做解析-noout表示不重新输出证书体只打印我们关心的字段。整条链路不依赖网络之外的任何东西拿到什么就是什么。参数说明-servername必须带它触发 SNI不带的话共享 IP 的虚拟主机很可能返回一张默认站点的证书——这是取错证书最常见的原因没有之一。/dev/null是防止s_client握手完成后继续等待标准输入把自己挂住2/dev/null屏蔽握手噪音。-ext subjectAltName需要较新的 openssl老版本改用-text再抓Subject Alternative Name那一段。-fingerprint -sha256出来的值别扔它是后面聚类的连接键。输出的subjectAltName里DNS:后面的每一项都是一个真实在用的域名。把它们全部收集起来逐个做一次同样的探测就是第一轮扩散。3.2 批量采集把 openssl 输出规整成可比对的 CSV一条一条手敲不现实写成脚本跑批顺便把第二轮扩散也做掉import subprocess, re, csv from pathlib import Path HOSTS Path(hosts.txt) # 每行一个纯域名不带协议和路径 OUT Path(certs.csv) def probe(host: str, timeout: int 10) - dict: cmd [openssl, s_client, -connect, f{host}:443, -servername, host, -showcerts] try: raw subprocess.run(cmd, inputb, capture_outputTrue, timeouttimeout).stdout # 解析失败时 x509 会返回空这里不抛异常统一记为 parse_fail txt subprocess.run( [openssl, x509, -noout, -subject, -issuer, -dates, -fingerprint, -sha256, -ext, subjectAltName], inputraw, capture_outputTrue, timeouttimeout ).stdout.decode(utf-8, ignore) except subprocess.TimeoutExpired: return {host: host, error: timeout} if notAfter not in txt: return {host: host, error: parse_fail} san sorted({s.lower() for s in re.findall(rDNS:([^,\s]), txt)}) return { host: host, not_after: re.search(rnotAfter(.), txt).group(1).strip(), issuer: re.search(rissuer(.), txt).group(1).strip(), fp_sha256: re.search(rFingerprint([0-9A-F:]), txt).group(1).strip(), sans: |.join(san), # 用竖线分隔方便后续 split 和去重 } rows, seen [], set() for host in [l.strip().lower() for l in HOSTS.read_text().splitlines() if l.strip()]: if host in seen: continue seen.add(host) rows.append(probe(host)) with OUT.open(w, newline, encodingutf-8) as f: w csv.DictWriter(f, fieldnames[host, not_after, issuer, fp_sha256, sans, error]) w.writeheader() for r in rows: w.writerow({k: r.get(k, ) for k in w.fieldnames})逻辑说明先对每个域名跑一次握手把 PEM 流转手给x509做解析再用正则把关键字段抠出来。解析失败和超时都单独记成error而不是丢掉因为“这个域名连不上”本身就是一条信息——尤其是它出现在对方官网的备案信息里却连 443 都握不上手的时候。参数说明timeout10是单域名上限跨境站点偶尔握手慢低于 5 秒会误判成失败。seen集合负责去重因为第二轮扩散会大量回填已经查过的域名。sans用竖线拼接而不是逗号是为了绕开证书里本身就存在的逗号分隔。第二轮扩散的规则很简单把第一轮所有sans里的域名拆出来剔除通配符形如*.开头的只保留主域、剔除与目标明显无关的公共托管域名回填到hosts.txt再跑一遍。两轮足够第三轮的收益会急剧下降而误报会明显上升。3.3 RDAP 字段归一化注册时间、注册人、名称服务器域名注册数据的老接口返回的是自由文本不同注册局的格式千差万别没法批处理。RDAP 是它的结构化继任者返回 JSON字段稳定适合写进流水线。import json, os, time, urllib.request # 服务入口由环境变量注入不写死地址便于换源和留痕 RDAP_BASE os.environ[RDAP_BASE].rstrip(/) def rdap_domain(domain: str, timeout: int 10, retries: int 2): url f{RDAP_BASE}/domain/{domain} req urllib.request.Request( url, headers{Accept: application/rdapjson, User-Agent: dd-osint/1.0}) for i in range(retries 1): try: with urllib.request.urlopen(req, timeouttimeout) as r: return json.loads(r.read().decode(utf-8)) except urllib.error.HTTPError as e: if e.code 404: # 域名不存在或无公开记录不是错误 return None if e.code 429: # 触发限流退避后重试 time.sleep(2 ** i) continue raise def normalize(d: dict) - dict: rec {handle: d.get(handle, ), status: ;.join(d.get(status, []))} # 事件时间统一转成 ISO8601 字符串后续可直接排序比较 for ev in d.get(events, []): key {registration: registered, expiration: expires, last changed: updated}.get(ev.get(eventAction)) if key: rec[key] ev.get(eventDate, ) rec[nameservers] sorted({ n.get(ldhName, ).lower() for n in d.get(nameservers, [])}) # 注册人邮箱藏在 vcardArray 里第二项才是真正的属性数组 rec[emails] sorted({ v[3] for e in d.get(entities, []) for v in e.get(vcardArray, [[], []])[1] if isinstance(v, list) and len(v) 4 and v[0] email }) return rec逻辑说明rdap_domain只负责取原始 JSON把 404 和 429 分开处理——404 代表这个域名没有公开记录属于正常结果429 是限流必须退避否则整批任务会被对方掐断。normalize负责把三种事件名映射成统一字段把名称服务器转小写去重把注册人邮箱从 vcard 结构里挖出来。参数说明RDAP_BASE走环境变量换源只需改环境不动代码也方便在报告里注明数据来自哪个入口。retries2配合2 ** i的指数退避实测足够应付大部分限流窗口。registered字段是这一整套里最重要的一个——它和主体成立日期的差值就是前面提到的那条时间线。注意注册人信息在不少后缀下已被隐私保护遮蔽。看到一屏REDACTED不要当成失败把它记成“不可得”转而从名称服务器和证书方向找关联。3.4 用并查集把“看起来无关”的域名聚成一簇单个域名的信息价值有限价值在于“哪些域名属于同一簇”。这一步用一个并查集就够不需要任何图数据库。class DSU: def __init__(self): self.p {} def find(self, x): self.p.setdefault(x, x) while self.p[x] ! x: # 路径压缩避免深链退化成 O(n) self.p[x] self.p[self.p[x]] x self.p[x] return x def union(self, a, b): ra, rb self.find(a), self.find(b) if ra ! rb: self.p[rb] ra # shared_attrs 的键是 (属性类型, 属性值)值是持有该属性的域名列表 # 权重email3fp_sha2562nameserver1 dsu, evidence DSU(), {} for (kind, value), domains in shared_attrs.items(): if len(domains) 2: continue for d in domains[1:]: dsu.union(domains[0], d) evidence.setdefault(dsu.find(domains[0]), []).append((kind, value, len(domains))) clusters {} for d in all_domains: clusters.setdefault(dsu.find(d), []).append(d)逻辑说明把每一个共享属性当成一条边把持相同属性的域名连起来最后取连通分量就是候选簇。evidence用来记录这个簇是靠什么连起来的写报告时直接引用。参数说明三类属性的权重必须分开——共享注册邮箱权重 3几乎可以认定同一控制人共享证书指纹权重 2大概率是同一套部署共享名称服务器权重 1只能算弱信号因为一个建站服务商底下可能挂着几千个毫无关系的域名。一个簇的成立门槛我定在“总权重 ≥ 3且至少包含两条不同类型的证据”低于这个门槛只进附录不进正文。4. 公告与合同附件三要素抽取和“三源一致”的落表方法4.1 主体、金额、日期三条正则覆盖八成场景尽调材料里大头是 PDF 和扫描件转出来的文本中标公告、合同附件、公开简历、宣传稿。这类文本不需要上大模型三条正则加一套归一化能覆盖八成场景而且结果可解释、可复核。import re, unicodedata PAT { # 金额允许千分位单位限定万元/元避免把数量、编号误抓成金额 amount: re.compile(r(?:金额|合同价|总价|报价|中标价)[^\d]{0,6} r([0-9][0-9,\.]*)\s*(万元|元)), # 日期只认 20xx 开头的四位年减少把编号当日期 date: re.compile(r(20\d{2})\s*[年\-/\.]\s*(0?[1-9]|1[0-2])\s*[月\-/\.]\s* r(0?[1-9]|[12]\d|3[01])), # 主体名中文全角括号要一起收否则会截断 org: re.compile(r[\u4e00-\u9fa5()·]{2,40}? r(?:有限公司|有限责任公司|合伙企业|研究院|事务所|集团)), } def norm_text(s: str) - str: s unicodedata.normalize(NFKC, s) # 全角转半角 s re.sub(r\s, , s) return s.replace(, ().replace(, )) def extract(text: str) - dict: t norm_text(text) return { amounts: [(float(a.replace(,, )), u) for a, u in PAT[amount].findall(t)], dates: [-.join(f{y}-{int(m):02d}-{int(d):02d} for y, m, d in PAT[date].findall(t))], orgs: sorted(set(PAT[org].findall(t))), }逻辑说明先做 NFKC 归一化把全角数字和全角括号拉平否则同一份材料里“”和“(”会让主体名截断成两个不同字符串。金额正则强制要求前面出现“金额/总价/中标价”这类触发词是为了压掉大量误报——纯数字串在公告里太多了不加锚点召回率上去了准确率会崩。参数说明[^\d]{0,6}允许触发词和数字之间夹少量文字实测“中标价为人民币”这种结构刚好卡在 4 到 5 个字符放宽到 6 够用。日期正则限制20\d{2}是有意的取舍历史项目里偶有 19xx 的日期但那类材料本身就该人工过一遍。主体名长度上限 40 是防跨句误抓。4.2 主体名归一用 difflib 挡掉“看起来一样”的错配同一家公司在不同材料里可能写成“某某科技某地有限公司”“某某科技有限公司”“某某科技”直接字符串比对会全部对不上但反过来放宽匹配又会把两家真不同的公司合并。这一步的阈值必须靠样本调。import difflib BRACKETS re.compile(r[()]) def canon(name: str) - str: s norm_text(name) s BRACKETS.sub(, s) # 去括号但保留括号内文字 for suffix in (有限责任公司, 有限公司, 集团): s s.replace(suffix, ) return s def same_entity(a: str, b: str, th: float 0.86) - bool: ca, cb canon(a), canon(b) if not ca or not cb: return False if ca cb: return True # 短名套长名时要求完整包含不做模糊匹配避免某科命中某科技 if len(ca) 4 and (ca in cb or cb in ca): return True return difflib.SequenceMatcher(None, ca, cb).ratio() th逻辑说明canon只做能确定无害的归一——去括号、去组织形式后缀括号内的地名或字号保留下来因为那往往正是区分主体的关键。same_entity先用包含关系处理短名套长名再退回相似度。参数说明阈值 0.86 是在几百对样本上试出来的经验值低于 0.80 会把“某某联合科技”和“某某联创科技”这类只差一两个字的异主体合并高于 0.92 又会漏掉大量真实别名。任何一次same_entity判定为真都必须人工看一遍两条原始字符串——这一步没有后悔药合错了后面全错。4.3 三源一致把交叉验证落成一张表交叉验证不能停留在脑子里要落成一张可以贴进报告的矩阵。待验证项材料自述 (S2)公开登记 (S1)基础设施 (S3)公开文本 (S1)结论主体名称某某科技有限公司某某科技有限公司—中标公示同名一致A 级成立时间称“深耕十年”成立 4 年域名首注册 3 年前无冲突待解释办公地址某写字楼 12 层同址登记 6 家企业—招聘信息同址一致但需关注集群注册列固定成“材料自述 / 公开登记 / 基础设施 / 公开文本”四栏行是待验证字段每格填“一致 / 冲突 / 不可得”。填完之后报告的结构其实已经出来了一致项进事实段冲突项进待解释段不可得项进局限说明段。提示整张表里最值钱的是“冲突”那一列。一份没有任何冲突项的尽调报告通常不是项目干净而是查得不够。5. 避坑OSINT 尽调里最容易翻车的五个地方5.1 五条踩坑记录每条都是真的疼取错证书。现象是抓到的证书和站点内容对不上甚至证书里的域名和你要查的毫无关系。原因是握手时没带 SNI或者目标挂在 CDN 后面返回的是边缘节点的默认证书。解决办法是强制带-servername并且拿到结果后做一次反向校验证书的 SAN 列表里必须包含目标域名本身不包含就判定为无效样本、重新探测。这个校验我后来直接写进了脚本省了很多二次排查。把域名注册时间当成公司成立时间。现象是报告里写“该主体实际经营仅一年”被对方一句“域名是后来换的”顶回来。原因是域名可以转让、可以更换、可以因为迁移而重新注册它反映的是“这个站点什么时候上线”不是“这家公司什么时候成立”。解决办法是把时间线层的所有字段都降级成“矛盾提示项”只用来触发追问绝不用来单独定性。这条我吃过一次亏后来所有涉及“成立早晚”的结论都要求两个以上 S1 支撑。同名主体误配。现象是把 A 公司的行政处罚挂到了 B 公司头上两家名字只差一个字。原因是只按名称匹配没有用唯一主体标识或注册地址做二次校验。解决办法定一个硬规则名称、注册号、注册地址三项里至少两项吻合才认定同一主体只有名称吻合的一律进“疑似”不进正文。这条规则写进流程之后误配率基本归零。共用名称服务器直接判关联。现象是把两家毫无关系的公司写成关联方报告当场失去可信度。原因是共用一个建站服务商或一个代理注册商名称服务器本来就会一样。解决办法是给不同属性分配权重共享注册邮箱权重最高共享证书指纹次之共享名称服务器只能算弱信号必须再叠加一条独立证据才能成簇。这个教训非常直接——弱信号不是不能用是不能单独用。采集没留痕。现象是一个月后复核页面已经改版公告已经下架你手上只剩一句“当时查到过”。原因是只存了结论没存证据。解决办法是把“采集时间 原始地址 内容哈希 本地快照”做成每条记录的必填字段缺一项就算这条证据不合格。这件事做起来枯燥但它是整个流程里唯一有效的后悔药。5.2 一份可以照着走的排查顺序顺序不是随便定的原则是“从最便宜、最不容易被修饰的信号开始”。第一步查登记信息。它最便宜也最硬因为改动成本高。第二步查域名与证书成本中等造假成本比登记信息低但比宣传材料高。第三步查公开文本包括公告、判决文书摘要、招聘信息成本开始上升噪声也大。第四步才看主体自述也就是官网和宣传册——把它们放在后面是为了避免先入为主。第五步才是人包括公开场合的发言、公开履历这一步最容易踩到隐私边界必须放在最后且有明确目的。每一步的输出都要能回答一个问题这一步有没有产生新的待解释项如果没有就往下走如果有就地展开不要急着走完全流程。新手常见的毛病是把五步都走一遍攒了五十页材料却一个待解释项都没深挖。5.3 采集边界三条自我约束第一只采公开可访问、不需要登录也不需要伪装身份就能看到的内容。需要提交虚假信息才能进入的地方采到的东西也不能用。第二不采集与尽调目标无关的个人信息——查到某个联系人的私人社交账号和“这家公司是否履约能力充足”没有关系就不要存。第三不把推断写成结论凡是推断出来的关联在报告里必须标注推断链条和所用证据让别人能推翻你。留痕本身就是一种约束。当每条记录都被要求写清来源和时间你会自然而然地少采很多“顺手看到但用不上”的东西报告的密度反而上去了。6. 线索到结论一张可信度打分卡和复核的三个动作材料齐了、冲突也列出来了最后一步是把它们压成一个能被人拿去做决策的结论。我一般用一张五维打分卡每维 0 到满分加权算总分但总分只作参考真正决定结论的是有没有单维触底。维度权重打分依据触底条件主体真实性30唯一主体标识、登记状态、经营范围是否覆盖标的业务登记状态异常或经营范围完全不覆盖履约能力证据25同类项目公开记录、中标公示、可核实的交付案例一条 S1 级履约证据都没有关联方风险20关联簇规模、共用注册邮箱或证书的域名数量出现未披露的同邮箱控制主体时间线自洽15成立、域名、证书、公告的先后关系存在两处以上无法解释的倒挂材料一致性10自述与公开登记的一致程度关键字段名称、地址、成立时间不一致任何一维触底直接给“不建议”不管总分多高。这个规则看起来粗暴但它挡住了大部分后来出问题的项目——出问题的从来不是“各项都中等”的主体而是某一项烂到底但被其他项平均掉的主体。复核我固定做三个动作。换源复核同一个结论换一个完全不同类型的来源再确认一次比如登记信息用另一个公开库交叉一遍。换人复核让没参与采集的人只看证据表看他能不能得出同样的结论得不出就说明推断链条有断点。换时间复核隔一周再跑一次域名和证书看有没有新增的 SAN 或变更的名称服务器——如果有说明对方在这段时间里动过基础设施这本身就是信息。最后说个我自己的习惯。每份报告交出去之前我会单独写一页“我没查到的”把 C 类字段和所有未解决的冲突项列清楚。刚开始觉得这是自曝其短做了几次之后发现恰恰相反委托方最认的往往就是这一页因为它证明你知道边界在哪里。尽调这一行能说清“我不知道什么”比能说出“我知道什么”更值钱。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。