4. 从 pom.xml 提取依赖许可证信息

从 pom.xml 提取依赖许可证信息 学习卡片

为什么 pom.xml 本身不够

Maven 的 pom.xml 只声明直接依赖,且 <licenses> 是可选字段,很多构件根本不写。真正决定合规风险的是解析后的完整依赖树:同一 groupId:artifactId 可能因不同路径引入多个版本,最终只有一个进入 classpath。因此提取流程必须分两步:先用 Maven 解析出有效依赖集合,再逐个读取构件元数据中的许可证。

pom.xml → 有效依赖树 → 许可证报告 pom.xml 直接依赖声明 可选 <licenses> 本地仓库 / 远程仓库 ~/.m2/repository *.pom 元数据 Maven 依赖解析 mvn dependency:tree 或 mvn dependency:list 冲突仲裁:最近优先 scope 过滤 结构化许可证报告 groupId artifactId version scope licenses[] license_source status 关键判断点 licenses 缺失 → 标记 UNKNOWN,需人工核查构件主页或 LICENSE 文件 同一构件多版本 → 只保留仲裁后进入 classpath 的版本 scope=test/provided → 通常不随产品分发,可单独标注 licenses 含 URL → 保留原始 URL 便于后续 SPDX 映射

用 Maven 命令拿到有效依赖清单

不要手工递归解析 pom,直接让 Maven 输出解析后的结果。以下命令生成包含 groupId、artifactId、version、scope 的列表:

mvn dependency:list -DoutputFile=deps.txt -DincludeScope=runtime -DoutputAbsoluteArtifactFilename=false

输出格式为 groupId:artifactId:type:version:scope。若需要树形结构排查冲突来源,改用:

mvn dependency:tree -DoutputFile=tree.txt -Dverbose

-Dverbose 会显示被省略的冲突版本,便于确认仲裁结果。

从构件元数据提取 licenses

每个已解析构件在本地仓库中都有对应的 .pom 文件,路径为:

~/.m2/repository/{groupId路径}/{artifactId}/{version}/{artifactId}-{version}.pom

其中 <licenses> 块结构如下:

<licenses>
  <license>
    <name>Apache License, Version 2.0</name>
    <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
    <distribution>repo</distribution>
  </license>
</licenses>

注意:<name> 是自由文本,不是 SPDX 标识符。例如 "Apache License, Version 2.0" 需要映射为 Apache-2.0,"The MIT License" 映射为 MIT。映射表应作为独立配置维护。

Python 提取脚本

以下脚本读取 deps.txt,定位每个构件的 pom 文件,解析 licenses 并输出 CSV:

import csv, os, re
from xml.etree import ElementTree as ET

M2 = os.path.expanduser("~/.m2/repository")
NS = {"m": "http://maven.apache.org/POM/4.0.0"}

# 自由文本 → SPDX 标识符映射(按需扩充)
SPDX_MAP = {
    "Apache License, Version 2.0": "Apache-2.0",
    "The Apache Software License, Version 2.0": "Apache-2.0",
    "The MIT License": "MIT",
    "MIT License": "MIT",
    "Eclipse Public License - v 1.0": "EPL-1.0",
    "Eclipse Public License v2.0": "EPL-2.0",
    "GNU Lesser General Public License": "LGPL-2.1-only",
}

def pom_path(g, a, v):
    return os.path.join(M2, *g.split("."), a, v, f"{a}-{v}.pom")

def extract_licenses(path):
    if not os.path.isfile(path):
        return [], "POM_NOT_FOUND"
    try:
        root = ET.parse(path).getroot()
    except ET.ParseError:
        return [], "POM_PARSE_ERROR"
    lics = []
    for lic in root.findall(".//m:licenses/m:license", NS):
        name = (lic.findtext("m:name", "", NS) or "").strip()
        url = (lic.findtext("m:url", "", NS) or "").strip()
        if name:
            lics.append({"name": name, "url": url,
                         "spdx": SPDX_MAP.get(name, "UNMAPPED")})
    return lics, ("OK" if lics else "NO_LICENSE")

rows = []
with open("deps.txt", encoding="utf-8") as f:
    for line in f:
        line = line.strip()
        if not line or line.startswith("["):
            continue
        parts = line.split(":")
        if len(parts) < 5:
            continue
        g, a, _, v, scope = parts[0], parts[1], parts[2], parts[3], parts[4]
        lics, status = extract_licenses(pom_path(g, a, v))
        if lics:
            for l in lics:
                rows.append([g, a, v, scope, l["name"], l["spdx"], l["url"], status])
        else:
            rows.append([g, a, v, scope, "", "UNKNOWN", "", status])

with open("license_report.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    w.writerow(["groupId","artifactId","version","scope",
                "license_name","spdx_id","license_url","status"])
    w.writerows(rows)

print(f"共 {len(rows)} 条记录,已写入 license_report.csv")

结果判读

status含义处理建议
OKpom 中声明了许可证检查 spdx_id 是否为 UNMAPPED,若是则补充映射
NO_LICENSEpom 存在但无 licenses 块访问构件主页或源码仓库查找 LICENSE 文件
POM_NOT_FOUND本地仓库无对应 pom先执行 mvn dependency:resolve 下载
POM_PARSE_ERRORpom 格式异常手动检查该文件,可能是损坏或非标准 pom
pom 中的 licenses 由构件作者填写,可能不准确或过时。对于高风险许可证(GPL、AGPL)或 UNKNOWN 状态,必须回溯到构件源码仓库中的 LICENSE 文件确认,不能仅凭 pom 声明下结论。
若项目使用多模块聚合 pom,在根目录执行 mvn dependency:list 会汇总所有子模块的依赖。如需按模块拆分,可在每个子模块目录单独执行,或在输出中保留模块路径信息。