1. 识别项目中的开源许可证声明文件
为什么许可证声明文件是合规扫描的第一入口
开源合规审查的第一步不是读代码,而是找声明。一个仓库可能把许可证放在根目录、子目录、包元数据或纯文本文件中,文件名也不统一。若只检查根目录的 LICENSE,很容易漏掉子模块、第三方目录或 vendored 代码携带的许可证,导致后续依赖清单和 SPDX 标识符解析建立在错误前提上。
本案例的任务边界很明确:扫描指定代码仓库根目录及子目录,定位 LICENSE、COPYING、NOTICE 等许可证声明文件,并输出文件路径与初步许可证类型判断。它不解析 SPDX 标识符,也不提取 package.json 依赖树,只解决“有哪些声明文件、它们在哪里、初步像什么许可证”这三个问题。
扫描对象与识别规则
许可证声明文件通常具有以下特征:文件名包含 LICENSE、LICENCE、COPYING、NOTICE、COPYRIGHT 等关键词,扩展名可能是 .txt、.md、无扩展名,甚至带 .license 后缀。扫描时应覆盖根目录和所有子目录,但需要排除 .git、node_modules、vendor 等可能产生大量噪声的目录——除非任务明确要求检查 vendored 代码。
初步许可证类型判断不依赖复杂解析,而是基于文件内容中的典型短语进行匹配。例如:MIT 许可证通常包含 Permission is hereby granted, free of charge;Apache-2.0 包含 Licensed under the Apache License, Version 2.0;GPL-3.0 包含 GNU GENERAL PUBLIC LICENSE 和 Version 3。这种判断是启发式的,用于快速分类,不能替代后续的 SPDX 标准化。
用 Python 实现扫描与初步判断
下面给出一个可直接运行的脚本。它使用标准库 os.walk 遍历目录,用文件名正则匹配声明文件,再读取文件前 2048 字节进行许可证类型启发式判断。脚本不依赖第三方包,适合作为合规扫描工具的最小原型。
import os
import re
# 文件名匹配模式:不区分大小写,覆盖常见许可证声明文件名
LICENSE_FILE_PATTERN = re.compile(
r'^(LICEN[CS]E|COPYING|NOTICE|COPYRIGHT)(\.(txt|md|rst))?$',
re.IGNORECASE
)
# 需要排除的目录,避免噪声
EXCLUDE_DIRS = {'.git', 'node_modules', 'vendor', '__pycache__', '.venv'}
# 许可证类型启发式规则:按优先级从高到低匹配
LICENSE_RULES = [
('Apache-2.0', [
r'Licensed under the Apache License, Version 2\.0',
r'Apache License\s+Version 2\.0',
]),
('GPL-3.0', [
r'GNU GENERAL PUBLIC LICENSE',
r'Version 3, 29 June 2007',
]),
('GPL-2.0', [
r'GNU GENERAL PUBLIC LICENSE',
r'Version 2, June 1991',
]),
('MIT', [
r'Permission is hereby granted, free of charge',
r'THE SOFTWARE IS PROVIDED "AS IS"',
]),
('BSD-3-Clause', [
r'Redistribution and use in source and binary forms',
r'Neither the name of',
]),
('ISC', [
r'Permission to use, copy, modify, and/or distribute',
]),
('MPL-2.0', [
r'Mozilla Public License Version 2\.0',
]),
]
def detect_license_type(text: str) -> str:
"""基于文件内容前若干字节进行许可证类型初步判断。"""
for license_name, patterns in LICENSE_RULES:
for pattern in patterns:
if re.search(pattern, text, re.IGNORECASE):
return license_name
return 'UNKNOWN'
def scan_license_files(repo_root: str):
"""扫描仓库根目录及子目录,返回声明文件路径与初步类型。"""
results = []
for dirpath, dirnames, filenames in os.walk(repo_root):
# 原地修改 dirnames,跳过排除目录
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
for filename in filenames:
if LICENSE_FILE_PATTERN.match(filename):
filepath = os.path.join(dirpath, filename)
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
head = f.read(2048)
except OSError:
head = ''
license_type = detect_license_type(head)
results.append((filepath, license_type))
return results
if __name__ == '__main__':
import sys
repo = sys.argv[1] if len(sys.argv) > 1 else '.'
for path, lic in scan_license_files(repo):
print(f'{path}\t{lic}')
运行方式与输出解读
将上述代码保存为 scan_license.py,在终端执行:
python scan_license.py /path/to/repo
输出为制表符分隔的两列:文件路径和初步许可证类型。例如:
/repo/LICENSE MIT
/repo/third_party/COPYING GPL-3.0
/repo/docs/LICENSE.md Apache-2.0
/repo/NOTICE UNKNOWN
其中 UNKNOWN 表示文件内容未命中任何启发式规则,需要人工复核。注意:NOTICE 文件通常不是许可证正文,而是版权声明或第三方组件说明,因此返回 UNKNOWN 是合理现象,不应直接当作许可证缺失处理。
常见误判与规避
- 把 NOTICE 当成许可证:NOTICE 文件通常包含第三方组件版权声明,不是许可证正文。扫描时应单独标记,不要与 LICENSE 混为一类。
- 忽略子目录中的 COPYING:很多项目在
third_party/或vendor/下放置独立许可证。若排除这些目录,会漏掉关键声明。 - 只匹配文件名不读内容:有些文件名为
LICENSE但内容为空或仅为占位符。读取前 2KB 内容可以识别这种情况。 - 编码问题导致读取失败:使用
errors='ignore'避免因非 UTF-8 编码中断扫描,但可能丢失部分匹配信息,需在结果中标记。
验证扫描结果是否完整
完成扫描后,用以下检查项确认结果可信:
| 检查项 | 通过条件 | 不通过时的处理 |
|---|---|---|
| 根目录 LICENSE 是否被找到 | 输出中包含根目录下的 LICENSE 或 LICENCE 文件 | 检查文件名正则是否覆盖无扩展名和大写变体 |
| 子目录 COPYING 是否被找到 | third_party/ 或 vendor/ 下的 COPYING 出现在结果中 | 确认排除目录列表未误伤目标目录 |
| NOTICE 是否被单独标记 | NOTICE 文件类型为 UNKNOWN 或单独分类 | 不要将 NOTICE 强制归入某个许可证类型 |
| 排除目录是否生效 | .git 和 node_modules 下无结果输出 | 检查 EXCLUDE_DIRS 集合是否包含这些目录名 |
| 空文件或占位文件是否被识别 | 内容为空的 LICENSE 返回 UNKNOWN | 人工复核该文件,确认是否为有效声明 |