theHarvester 中的 ASN 组织归属持久化:基于来源证据的规范化 SQLite 关系设计
【免费下载链接】theHarvesterE-mails, subdomains and names Harvester - OSINT项目地址: https://gitcode.com/GitHub_Trending/th/theHarvester
导读
本文围绕 theHarvester 仓库中的架构决策记录 ADR-0005(Persist ASN organization attribution as sourced evidence) 展开,讲解项目如何把"某组织(organization)持有/通告某 ASN"这一信息,从随意的属性标签升级为带来源(sourced)、类型化(typed)、可校验(canonical)的持久化证据。你将掌握:归属观察(AsnAttributionObservation)的数据模型与规范化规则、SQLite 中asn_attributions关系表的存储结构、JSONL 与 API 的投影格式,以及它与 RouteViews 网络纵深(pivot)的衔接边界。读完本文即可在二次开发或审计 thisHarvester 结果数据库时准确理解组织归属数据的写入、读取与校验链路。
本文涉及文件均为仓库现有内容,可对照 theHarvester/lib/asn_attribution.py、theHarvester/lib/database.py、theHarvester/lib/completed_result.py 等路径深入阅读。
背景:为什么组织标签不能作为 ASN 的可变属性
ADR-0005 首先回答了一个数据建模问题:ASN 的组织归属(organization attribution)应该以什么形态存储?
Provider 标签本身并不可靠,ADR 明确列出的现实约束有三点:
- 可能缺失:某个数据源(source)返回的 ASN 结果里并没有组织名;
- 会随时间变化:组织改名、重组、运营主体变更都会导致同一 ASN 的标签前后不一致;
- 跨来源不一致:不同 provider(如 Onyphe、Shodan、URLScan)对同一 ASN 给出的组织名可能互相冲突。
如果把组织标签当作 ASN 结果上的一个"可变属性"直接塞进results.details_json,这些冲突会被静默覆盖,且无法支撑"跨多次运行(cross-run)查询同一 ASN 的所有组织归属"这类需求。因此 ADR-0005 的决策是:归一化(normalized)为独立的、带来源的关系记录,每条记录把 ASN 结果、关联的主机名或 IP 结果、以及产生该证据的 source/action 执行三者显式关联起来。
同时,决策划清了语义边界:组织归属只服务于操作者复核(operator review),它:
- 不代表所有权(ownership)判定;
- 不构成授权(authorization)依据;
- 不能扩展目标范围(target scope);
- 其组织标签本身不能作为过滤条件——但它精确的 IP 主体(exact IP subject)可以作为自动触发 RouteViews 纵深查证的输入。
决策要点:类型化证据 + 归一化关系行
ADR 的完整决策是:
将每条 ASN 组织归属存储为一个类型化的领域观察(typed domain observation),由一条归一化的 SQLite 关系行支撑,该行关联 run、ASN 结果、相关主机名或 IP 结果、以及 source 执行。SQLAlchemy 行对
ResultStore保持私有,观察通过 JSONL 与 API 投影输出,而不是把组织标签当作 ASN 的可变属性。
落到代码上,这一决策由三层结构落实:
- 领域对象层:theHarvester/lib/asn_attribution.py 中定义的
AsnAttributionObservation冻结数据类; - 存储层:theHarvester/lib/database.py 中定义的
_AsnAttributionRowSQLAlchemy 模型(表名asn_attributions),且仅由ResultStore内部使用; - 投影层:JSONL 序列化(completed_result.py)与 API 响应模型(run_models.py)。
领域模型:AsnAttributionObservation 及其规范化规则
AsnAttributionObservation是 frozen + slots 的不可变数据类,字段与约束如下(asn_attribution.py):
| 字段 | 类型 | 规范化规则 |
|---|---|---|
producer_kind | 'source'/'action' | 必须是二者之一 |
producer | str | 非空、UTF-8 可编码、长度 ≤ 255、不含 Cc/Cf 控制类字符 |
asn | str | 经normalize_asn归一为AS<number>形式 |
organization_label | str | 非空、UTF-8 可编码、长度 ≤ 255(MAX_ORGANIZATION_LABEL_LENGTH)、不含控制字符 |
subject_kind | 'hostname'/'ip' | 二者之一 |
subject_value | str | hostname 经normalize_hostname;IP 必须是规范地址(拒绝 IPv6 scope 标识%) |
collected_at | datetime | 必须带时区,统一转为 UTC |
__post_init__中所有字段都在构造时完成校验与规范化,任何违规都抛出ValueError,体现了**构造即校验、失败即关闭(fail closed)**的设计。其中:
asn的规范化实现在 result_values.py:去掉AS前缀(大小写不敏感)、只接受 ASCII 十进制整数、范围0 ≤ ASN ≤ 4_294_967_295,最终输出AS<number>规范形式;subject的规范化在_normalize_subject(asn_attribution.py):hostname 走域名归一化,IP 通过ipaddress.ip_address生成规范字符串,且明确拒绝含%的 IPv6 scope 形式;_normalize_name校验非空、UTF-8 可编码、长度上限以及 Unicode 控制字符类别(unicodedata.category为Cc/Cf的字符一律拒绝)。
观察对象还提供两个关键方法:
sort_key():按(asn, producer_kind, producer, organization_label, subject_kind, subject_value, collected_at)排序,用于去重与稳定排序;detail():输出投影字典,结构固定为:
{ 'type': 'organization-attribution', 'producer_kind': 'source', # 或 'action' 'producer': 'urlscan', 'organization_label': 'Example Transit', 'subject': {'type': 'ip', 'value': '192.0.2.10'}, 'collected_at': '2026-09-13T05:11:54Z', # format_utc 输出的 UTC 时间戳 }规范化集合与严格解析
模块级函数定义了观察集合的"规范化"含义(asn_attribution.py):
canonical_asn_attributions(observations):sorted(set(...), key=sort_key),即去重 + 排序后才算规范化;asn_attribution_details(observations):把规范化集合逐一投影为detail()字典列表;parse_asn_attribution_details(asn, details):反向解析。它对输入做严格全等校验:要求details是非空数组、每个元素必须是恰好包含type / producer_kind / producer / organization_label / subject / collected_at六个键的字典、subject必须恰好包含type / value两个键、collected_at必须是可解析的 UTC 时间戳,最终还要求detail != observation.detail()时直接抛错——即读回来的内容必须与写出的规范化结构完全一致,不允许任何变体或多余字段。
存储层:asn_attributions 关系表的结构与生命周期
表结构与外键
SQLAlchemy 模型_AsnAttributionRow(database.py)对应表asn_attributions:
| 列 | 类型 | 说明 |
|---|---|---|
run_id | Text(主键) | 所属运行 |
position | int(主键) | 行内序号 |
asn_result_position | int | 外键 →results(run_id, position),指向该 run 内的 ASN 结果 |
subject_result_position | int | 外键 →results(run_id, position),指向主机名或 IP 结果 |
execution_position | int | 外键 →executions(run_id, position),指向产生两者的 source/action 执行 |
organization_label | Text | 组织标签原文 |
collected_at | Text | ISO 格式 UTC 时间戳 |
三条外键约束都带ondelete='CASCADE':run、被引用的结果或执行被删除时,归属行随之级联删除。行不存 ASN 值本身,只存位置指针——ASN 值由results表中对应行承载,归属行通过asn_result_position引用,这与 ADR 中"关系行 linking the run, ASN result, related hostname or IP result, and source execution"的描述完全一致。
初始化:schema 保持 8,缺表自动创建
ADR 提到"当前未发布(unreleased)的 schema 仍为 8,初始化时若缺表则自动创建"。代码印证于 database.py 的SCHEMA_VERSION = 8:初始化流程(database.py)读取PRAGMA user_version,若版本高于 8 直接报错拒绝;随后执行_Base.metadata.create_all(database.py),asn_attributions作为 metadata 中已注册的表,在数据库为空或旧库缺少该表时会被自动创建,无需提升 schema 版本——这正是"创建额外表"而非"破坏性迁移"的设计意图。
写入:save_run 的三段引用解析
写入逻辑位于ResultStore.save_run(database.py)。写入前先建立两张查找表:
result_positions:{(kind, value): position},覆盖 run 内全部结果;execution_positions:{(producer_kind, name): position},覆盖全部 source 与 action 执行。
随后每条归属观察被映射为一行:
_AsnAttributionRow( run_id=run_id, position=position, asn_result_position=result_positions[('asn', attribution.asn)], subject_result_position=result_positions[(attribution.subject_kind, attribution.subject_value)], execution_position=execution_positions[(attribution.producer_kind, attribution.producer)], organization_label=attribution.organization_label, collected_at=attribution.collected_at.isoformat(), )注意:归属对象中的asn与subject都是规范化后的值,必须能在本 run 的results中找到对应行,否则KeyError会让整个事务失败——这就是"每条归属必须引用规范的 ASN 结果、精确的主机名/IP 主体、以及产生两者的 source 或 action"的强制保证。
读取:load_run 的反向装配与完整性校验
读取时(database.py),load_run按position顺序取出全部attribution_rows,对每一行执行反向装配:
- 通过
asn_result_position/subject_result_position/execution_position三处指针,分别查出 ASN 结果、主体结果与执行记录; - 任一指针落空即抛出
ResultStoreError('Persisted ASN attribution references missing evidence'); - 校验执行记录的
producer_kind合法性,反推producer_kind(source/action); - 用 ISO 时间戳重建
collected_at,构造AsnAttributionObservation; - 全部装配完成后调用
canonical_asn_attributions与原始列表比较,顺序不一致同样抛错。
也就是说,持久化数据在每次加载时都会被重新规范化并全等校验,任何缺失、重复或非规范化(noncanonical)的关系都会导致 fail closed。这一点在测试 test_completed_persistence.py 中有直接覆盖:通过UPDATE asn_attributions SET asn_result_position = subject_result_position、INSERT INTO asn_attributions ...等方式注入损坏数据后,load_run必须抛错拒绝。
投影层:JSONL 与 API 如何暴露归属观察
ADR 强调 SQLAlchemy 行对ResultStore保持私有,观察通过JSONL 与 API对外投影。
JSONL 序列化
在 completed_result.py 中,CompletedResult.to_jsonl()先把归属按 ASN 分组为attribution_by_asn,当遇到kind == 'asn'且该 ASN 存在归属时,向该行记录写入:
{"type": "asn", "value": "AS64500", "observations": [{"type": "organization-attribution", ...}]}反向路径from_jsonl(completed_result.py)则调用parse_asn_attribution_details严格解析,失败时以ValueError拒绝整条记录。此外CompletedResult在构造后还有一条全局一致性校验(completed_result.py):所有归属必须已去重排序,collected_at必须落在本次运行的started_at~completed_at区间内,且每条归属的(asn, subject)必须真实存在于 run 的结果集合中、其 producer 必须与执行记录吻合——任何一条不满足都拒绝该结果对象。
API 响应模型
API 侧的投影模型定义在 run_models.py:
AsnAttributionSubjectResponse:type限定为'hostname' | 'ip',value为字符串,extra='forbid';AsnAttributionObservationResponse:type限定为'organization-attribution',字段与detail()一一对应,同样extra='forbid'。
请求/响应处理在 run_evidence.py:先拒绝含未知字段的输入,再调用parse_asn_attribution_details解析,最后用asn_attribution_details重新规范化输出。这套"解析 → 规范化 → 输出"的往返保证了 API 与 JSONL 两个入口看到的归属结构完全一致。
边界约束:归属不可授权、不可过滤,但可触发 RouteViews 纵深
ADR 的 Consequences 部分明确了两条安全边界:
- 归属可以相互冲突地独立可见:不同 provider 标签冲突时,归一化关系保留全部冲突记录,而不是覆盖其中一方;
- 归属不能授权目标范围扩展:组织标签本身不是过滤条件,也不会自动扩大扫描授权范围。
唯一的自动化联动是RouteViews 网络纵深(pivot)。在 routeviews.py 中,enrich_routeviews的文档字符串写明:
Domain runs automatically pass harvested IPs that have sourced IP-to-ASN attribution. Bare ASN findings are not expanded into complete prefix inventories; that requires an explicit ASN target.
即:域名类运行中,只有带来源的 IP→ASN 归属所对应的精确 IP 才会被自动送入 RouteViews 查询;孤立的 ASN 结果(无归属关联、无显式 ASN 目标)不会被自动展开成完整前缀清单。归属的"精确 IP 主体"是触发纵深查询的输入,但组织标签不参与过滤决策。对应地,run_models.py中关于该能力的描述为"Enrich discovered IPs with sourced ASN attribution, or an explicitly targeted ASN or IP address"(run_models.py),进一步印证:要么是带来源的自动归属,要么是用户显式指定的 ASN/IP 目标。
源码验证路径小结
| 关注点 | 验证位置 |
|---|---|
| 观察数据类与规范化 | asn_attribution.py |
| ASN 归一化规则 | result_values.py |
| 关系表定义 | database.py |
| schema 版本与建表 | database.py、database.py |
| 写入/读取装配 | database.py、database.py |
| JSONL 投影与校验 | completed_result.py、completed_result.py |
| API 模型与解析 | run_models.py、run_evidence.py |
| RouteViews 联动边界 | routeviews.py |
| 损坏数据 fail-closed 测试 | test_completed_persistence.py |
| 来源侧产出归属的测试 | test_shodan_engine.py、test_onyphe.py、test_urlscan.py、test_source_runner.py |
总结
ADR-0005 为 theHarvester 确立了一条清晰的证据持久化原则:组织归属是"来源证据",不是"ASN 属性"。它以不可变领域对象 + 归一化 SQLite 关系行承载,通过外键把 run、ASN 结果、主机名/IP 主体和 source/action 执行钉死在一条记录里;写入与读取两侧都执行去重、排序、引用完整性与全等校验,任何缺失、重复或非规范的关系都会 fail closed。对外,它通过 JSONL 与 API 投影为结构固定的organization-attribution观察;对内,它只作为操作者复核材料与 RouteViews 精确 IP 纵深查证的输入,既不充当过滤条件,也不构成目标范围授权。这种设计在保留跨来源冲突可见性的同时,让 ASN 组织归属成为可查询、可追溯、可复核的结构化证据。
【免费下载链接】theHarvesterE-mails, subdomains and names Harvester - OSINT项目地址: https://gitcode.com/GitHub_Trending/th/theHarvester
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考