实现原理:批量扫描与拓扑
对应代码:
pkg/batchscanner.go(并发)+pkg/mappingdata.go(去重/聚合)+pkg/chaintopology.go(拓扑)
map-scan 能批量扫数百上千目标并产出测绘摘要。本页讲并发模型、去重、聚合、信任拓扑四个核心。
BatchScanConfig 字段与默认值
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
Concurrency | int | 100 | 并发 worker 数 |
Timeout | time.Duration | 5s | 单目标拨号超时 |
RateLimit | int | 0(不限速) | 每秒连接数上限 |
RetryCount | int | 2 | 重试次数(总尝试 = RetryCount+1) |
RetryDelay | time.Duration | 500ms | 重试退避 |
SkipTLSVerify | bool | true | 默认跳过校验(适配自签/IP) |
ServerName | string | "" | SNI 覆盖 |
NewBatchScanner 归一化:Concurrency<=0→100;Timeout<=0→5s;RetryCount<0→2;RetryDelay<=0→500ms。RateLimit 不归一化(0 即不限速,newScanRateLimiter(0) 返回 nil)。
并发模型:worker pool
关键设计:
- worker 数 =
min(Concurrency, len(targets))(targets 非空时) - jobs channel 无缓冲:每个任务必须有 worker 接收
- 预分配保序:结果按 index 写回
results[idx],输出顺序与输入一致 - ctx 取消:关闭 jobs、
return results, ctx.Err()
为什么 worker 数 = min(Concurrency, len(targets))
当目标数小于配置并发度时,开再多 worker 也是空跑——NewBatchScanner 用 min 钳制,避免无意义 goroutine。预分配 results 切片 + 按 index 写回是为了输出顺序与输入一致,这对 CSV/JSON 报告很重要。
scanWithRetry 重试逻辑
for attempt := 0; attempt <= RetryCount; attempt++(总尝试 RetryCount+1 次);attempt>0 时先 time.After(RetryDelay);成功立即返回。
scanRateLimiter 令牌桶
scanOnce 单目标采集
BatchScanResult 字段:Target / CertChain []*x509.Certificate(json:-) / CertChainDER [][]byte / TLSVersion uint16 / CipherSuite uint16 / ServerName / ObservedAt / Duration / Error(json:-) / ErrorMessage
目标展开
ScanFromIPRange(L267-285):从 ip.Mask(mask) 起,ipNet.Contains(current) 期间 incrementIP(从末字节自增、进位)。ports 默认 [443]。
去重:CertDedup
三种 DedupMethod 与 key 构造(mappingdata.go L25-29, L178-192):
| Method | key type 字符串 | value 构造 |
|---|---|---|
DedupBySPKI | "spki_sha256" | sha256(cert.RawSubjectPublicKeyInfo) hex |
DedupByCertFingerprint | "cert_sha256" | sha256(cert.Raw) hex |
DedupBySerialIssuer | "serial_issuer" | sha256(SerialNumber.String() + "\x00" + Issuer.String()) hex |
NewCertDedup() 无参时默认同时用 DedupByCertFingerprint + DedupBySPKI。Add 时任一 key 命中即判重;新 cert 把所有 keys 都加入 seen。
三种去重 key 怎么选
DedupBySPKI:同公钥的证书视为同一资产(续期会去重)——适合"资产盘点"看唯一公钥数。DedupByCertFingerprint:整张证书相同才去重——适合"证书清单"看唯一证书数。DedupBySerialIssuer:序列号+签发者相同才去重——适合跨 CA 体系比对。 默认双 key(cert+SPKI)任一命中即去重,是"既看证也看公钥"的折中。
聚合:六维分桶
AggregateCertificateAssets(L333-367)按六维分桶:
- 每桶 key 为空时用
"unknown" - ASN key 格式
"AS%d %s"(如AS13335 Cloudflare) - 最多 5 个示例 CN:
if len(samples[kind][key]) < 5 { samples[kind][key][sample] = true } Percentage = count*100/total
organizationKey(L241-260):ToLower + 保留字母数字与空格 + 去停用词 {inc, llc, limited, ltd, corp, corporation, co, company, gmbh, sa}。
聚类:并查集
ClusterCertificates(L443-518)默认 sanOverlapThreshold=0.5,三种 cluster reason:
sanJaccard(L520-539):交集/并集,双方都空返回 0。
信任拓扑
BuildTrustChainTopologyFromAssets(chaintopology.go)建图:
trustNodeID 构造(L117-125)
if len(cert.Raw) > 0 {
id = computeHashHex(cert.Raw) // cert SHA-256 hex
} else {
id = sha256(Subject.String() + "\x00" + SerialNumber.String())
}Roots / Leaves 判定(L56-57, L92-97)
遍历每条 chain:chain[certIndex+1] 是 chain[certIndex] 的 issuer → incoming[subjectID]=true, outgoing[issuerID]=true。
- Root =
!incoming[node.ID](无人签发它) - Leaf =
!outgoing[node.ID](它不签发任何人) - 自签:
cert.CheckSignatureFrom(cert)==nil→ 加 self-loop edgerelation="self_signed"
edgeByKey 去重 key = issuerID + "\x00" + subjectID + "\x00" + relation。
拓扑节点/边字段
type TrustChainNode struct {
ID, CertSHA256, SPKISHA256, Subject, CommonName, OrgKey string
IsCA, SelfSigned bool
ChainIndexes []int
Targets []string
}
type TrustChainEdge struct {
FromIssuerID, ToSubjectID, Relation string
ChainIndexes []int
Targets []string
}离线解析:BatchParseCertificateFiles
config.Concurrency 默认 16;bufio.Scanner 缓冲 64KB 初始、16MB 最大。
离线解析的缓冲设计
bufio.Scanner 默认 64KB,遇到超长 PEM(含完整链或大量 SAN)会 ErrTooLong。cert-skills 把上限提到 16MB——足以容纳任何现实证书文件,又不至于无脑占内存。这个值是"够用且不浪费"的工程权衡。
完整测绘摘要
map-scan 一次输出含四块:
这就是测绘三段式的"发现 + 归类"阶段,详见 网络空间测绘。
下一步
- 测绘手册 → 网络空间测绘
- 测绘工作流 → 网络空间测绘扫描工作流
- 指纹化 → JARM 指纹