依赖许可证审计技能
来自 Wikiprompt,自由的提示词百科全书
依赖许可证审计技能 一个完整的AI技能,用于审计依赖许可证,包括工作流程、参考资料、模板、示例和Python脚本。它帮助工程团队在发布前识别许可证风险。
提示词内容收藏
🌐
---
name: dependency-license-audit
description: 审计项目依赖项的许可证,根据实际发布方式(SaaS、分发应用或库、内部工具)进行:将许可证名称规范化为 SPDX,解析 OR 和 AND 表达式,标记缺失、AGPL、GPL、源代码可用及不兼容的许可证,并生成排序的修复计划和通知列表。包含一个经过测试的 stdlib Python 审计器。
---
# 依赖许可证审计
你帮助工程团队在发布、收购审查或客户安全问卷发现问题之前,找出依赖项中的许可证问题。你将原始依赖列表转化为一份简短、排序的待修复事项清单,并用通俗语言解释每一项。你是一位细心的工程师,而非律师:你标记风险,并指出何时需要具备法律培训的人员做出决定。
## 本技能中的文件
- `scripts/audit_licenses.py` - 规范化并分类每个依赖许可证,并根据所选分发模型评级(仅 Python 3 标准库)
- `references/license-categories.md` - 宽松、弱 Copyleft、强 Copyleft、网络 Copyleft 和源代码可用在实际中的含义,附常见 SPDX ID
- `references/remediation-playbook.md` - 如何修复每类发现,从替换包到添加通知
- `templates/license-audit-report.md` - 报告布局
- `examples/example-saas-audit.md` - 一个 Node.js SaaS 后端的完整审计示例
## 工作流程
### 1. 了解项目的发布方式
询问,或推断并声明为假设:
- 项目许可证:专有(闭源)或开源许可证(SPDX ID)。
- 分发方式:`saas`(用户仅通过网络访问)、`distributed`(移动或桌面应用、本地安装、设备、SDK、发布的库、提供给客户的容器镜像)或 `internal`(仅员工使用)。
- 任何公司政策:始终允许和始终禁止的许可证。
同一个依赖项可能对一种模式没问题,对另一种模式却是阻碍,所以切勿跳过此步骤。
### 2. 获取依赖清单
优先使用工具输出而非手工列表,并包含传递依赖:
- Python:`pip-licenses --format=json --with-system`(或 `pip-licenses --format=csv`)。
- Node.js:`npx license-checker --json --production`(对开发工具再次运行,不带 `--production`)。
- 其他:从 SBOM 或包管理器导出的 CSV,包含 `name,version,license,scope`。
将构建、测试和 lint 工具标记为 `scope=dev`;它们不随产品发布,因此其发现降级为 INFO。
### 3. 运行审计
```bash
python3 scripts/audit_licenses.py deps.csv --project-license proprietary --distribution saas
python3 scripts/audit_licenses.py licenses.json --distribution distributed --deny AGPL-3.0-only
python3 scripts/audit_licenses.py deps.csv --json
```
脚本输出严重性计数、每个包及其规范化许可证和类别的表格、带原因的发现、版本间许可证变更,以及需要在第三方通知中包含文本的许可证。退出码 1 表示至少有一个 HIGH 发现。
如果无法运行脚本,请使用 `references/license-categories.md` 手动分类每个许可证,并在报告中说明。
### 4. 得出结论前进行验证
- 对每个 HIGH 或 WARN 发现,检查包在其仓库或包存档中的实际 LICENSE 文件;元数据常常错误或过时。
- 对于“缺失”或“SEE LICENSE IN”条目,打开引用的文件并根据其文本分类。
- 对于双重许可的包(OR),记录你选择的选项。
- 对于许可证在版本间变更的包,确认实际安装的版本(锁文件)。
### 5. 建议和报告
使用 `references/remediation-playbook.md` 为每个发现提供一个具体行动:替换、隔离、固定到旧的宽松版本、购买商业许可证、合规(发布源代码或添加通知),或接受并记录原因。填写 `templates/license-audit-report.md`,如 `examples/example-saas-audit.md` 所示。
## 规则
- 切勿以法律结论说某许可证“没问题”或“非法”。说明该许可证通常要求什么,以及何时应咨询法律顾问。
- 不要根据包名或流行度猜测许可证。未知保持未知,直到有人阅读许可证文本。
- 根据用户的分发模型评估风险,并指出如果模型改变会有什么变化(例如今天是 SaaS,明年是本地部署)。
- 保持报告简短:先列阻碍项,然后是义务(通知、源代码提供),最后是干净列表的计数。
- 将审计视为快照;建议在每次依赖变更时在 CI 中运行。
FILE:references/license-categories.md
# 实际中的许可证类别
脚本中的严重性取决于类别和项目的发布方式。本页用通俗语言解释这些类别。这是工程总结,不是法律建议。
## 公有领域及近似公有领域
SPDX:`0BSD`、`Unlicense`、`CC0-1.0`、`MIT-0`、`WTFPL`
- 无实质性义务。保留通知仍是良好实践。
## 宽松
SPDX:`MIT`、`ISC`、`BSD-2-Clause`、`BSD-3-Clause`、`Apache-2.0`、`Zlib`、`BSL-1.0`(Boost)、`PSF-2.0`、`Python-2.0`、`CC-BY-4.0`(数据,如 caniuse-lite)
- 可在闭源产品中使用、修改和发布。
- 保留版权声明和许可证文本,通常在第三方通知文件或“关于 / 许可证”屏幕中。
- Apache-2.0 还要求保留任何 NOTICE 文件,声明对其文件的重大更改,并包含专利授权,若你就该代码中的专利提起诉讼则授权终止。
- BSD-3-Clause 禁止使用作者姓名推广你的产品。
## 弱 Copyleft(文件或库级别)
SPDX:`LGPL-2.1-*`、`LGPL-3.0-*`、`MPL-2.0`、`EPL-1.0`、`EPL-2.0`、`CDDL-1.0`、`CDDL-1.1`、带链接例外的 GPL(例如 `GPL-2.0-only WITH Classpath-exception-2.0`)
- 你可以将其与闭源代码结合,但分发时对库自身文件的更改必须以相同许可证共享。
- LGPL:用户必须能够用自己的版本替换该库。动态链接(共享库、独立 jar、npm 包)通常使这变得容易;静态链接或捆绑到单个二进制文件需要谨慎。
- MPL-2.0:Copyleft 按文件适用。保持 MPL 文件独立,并发布对这些文件的更改。
## 强 Copyleft
SPDX:`GPL-2.0-only`、`GPL-2.0-or-later`、`GPL-3.0-only`、`GPL-3.0-or-later`
- 如果你分发包含或链接 GPL 代码的软件,合并后的作品必须以 GPL 及其源代码提供。
- 在你自己的服务器上运行 GPL 软件并让用户通过网络访问不是分发,因此纯 SaaS 使用通常允许。向客户分发应用、本地安装程序、设备固件或容器镜像是分发。
- 版本很重要:`GPL-2.0-only` 代码不能与 GPL-3.0 或 Apache-2.0 代码合并为一个作品。`-or-later` 提供更多空间。
## 网络 Copyleft
SPDX:`AGPL-3.0-only`、`AGPL-3.0-or-later`、`SSPL-1.0`(未经 OSI 批准)
- 类似 GPL,外加:如果用户通过网络与修改版本交互,你必须向其提供源代码。
- 对于闭源 SaaS 产品,这是最常见的阻碍。即使未修改的使用也会触发大多数公司的内部审查。
- SSPL 更进一步:将软件作为服务提供可能要求发布整个服务栈的源代码。
## 源代码可用和非商业
SPDX 或名称:`BUSL-1.1`(商业源代码许可证)、`Elastic-2.0`、Commons Clause 附加条款、`CC-BY-NC-*`、`PolyForm-Noncommercial-1.0.0`
- 源代码可见,但使用受限:通常禁止竞争性托管服务、超过限制的生产使用或任何商业使用,有时直到变更日期。
- 始终需要审查确切条款。许多此类许可证在几年后切换到开源许可证,因此旧版或新版可能不同。
## 未知
- 空许可证字段、`UNKNOWN`、`NOASSERTION`、`UNLICENSED`、`SEE LICENSE IN <file>`、`LicenseRef-*` 或脚本无法识别的名称。
- “无许可证”意味着保留所有权利:你无权使用它。找到真实条款或替换包。
## 表达式
- `MIT OR Apache-2.0`:你可以选择任一;选择适合你的并记录选择。
- `MIT AND BSD-3-Clause`:你必须遵守两者;最严格者设定类别。
- `GPL-2.0-only WITH <exception>`:例外缩小了 Copyleft 范围;请阅读。
FILE:references/remediation-playbook.md
# 修复手册
为每个发现选择一个行动并记录负责人。优先选择消除风险的最便宜行动。
## 工作顺序
1. 未知和缺失许可证(你可能根本无权使用该代码)。
2. 发布或托管代码中的网络 Copyleft 和源代码可用包。
3. 分发产品中的强 Copyleft,以及开源项目中的版本不兼容。
4. 弱 Copyleft 义务(链接、发布更改)。
5. 其他所有内容的通知。
## 行动
### 找到真实许可证
- 打开包的仓库和存档,阅读 LICENSE、COPYING 或主源文件头部。
- 检查元数据是否只是过时(旧包和分支的常见情况)。
- 如果确实没有许可证,请维护者添加(在请求中链接 choosealicense.com)或替换包。
### 替换包
- 搜索具有宽松许可证和类似 API 的维护替代品。
- 以小时为单位估算工作量,并将其添加到积压中,注明其阻碍的发布。
- 对于传递依赖,查找引入它的直接依赖;不同版本或配置标志通常可以去掉它。
### 固定到旧许可证下的版本
- 当项目从宽松许可证切换到源代码可用(或反之)时,旧版本保留旧许可证。
- 固定是短期修复:无安全更新。安排适当的替换。
### 隔离它
- 将 Copyleft 工具作为独立进程或服务运行,通过命令行、HTTP 或队列与你的代码通信,而不是链接到你的代码中。
- 尽可能保持未修改。记录边界。
- 这是使用 GPL 命令行工具的常见方式,但 AGPL 和 SSPL 需法律顾问确认。
### 购买商业许可证
- 许多双重许可项目(开源加商业)出售消除 Copyleft 义务的许可证。注意价格和续订条款。
### 合规
- 弱 Copyleft:将库保持为独立、可替换的文件或包;发布对其自身文件的任何更改;包含其许可证。
- 分发产品中的 GPL:仅当整个产品可以在兼容条款下开源。这是商业决策,而非工程决策。
- 托管服务中的 AGPL:向服务用户提供相应源代码,包括你的更改。
### 接受并记录原因
- 仅开发工具(linter、测试运行器、构建工具)从不发布通常没问题。记录“仅开发,不分发”。
- 仅内部使用 GPL 或 AGPL 工具通常没问题。记录谁使用以及不暴露给外部用户。
## 第三方通知
- 从清单生成通知文件:包、版本、许可证、版权行、完整许可证文本(每个不同许可证文本一份副本)。
- 按原样包含 Apache NOTICE 文件。
- 在应用内(“设置 > 关于 > 许可证”)、安装程序和容器镜像中发布;对于 SaaS,从页脚或文档链接。
## 保持干净
- 在每次锁文件变更时在 CI 中运行审计,并在 HIGH 时使构建失败。
- 与法律顾问商定允许列表(例如 MIT、BSD、ISC、Apache-2.0)和禁止列表(例如 AGPL-3.0、SSPL-1.0、BUSL-1.1),并通过 `--allow` 和 `--deny` 传递。
FILE:templates/license-audit-report.md
# 依赖许可证审计:<项目名称>
日期:<YYYY-MM-DD>
项目许可证:<proprietary | SPDX ID>
分发方式:<saas | distributed | internal>(<一行:用户如何获得软件>)
清单来源:<工具和命令、锁文件提交>
审计包数:<n>(<运行时 n>、<开发 n>)
## 摘要
- 阻碍项(HIGH):<n> - <一行说明>
- 义务(WARN):<n> - <一行说明>
- 备注(INFO):<n>
- 干净(OK):<n>
- 结论:<可发布 | 修复后发布 | 受阻>,因为<原因>。
## 阻碍项
| 包 | 版本 | 许可证(发现时) | 类别 | 对我们的重要性 | 行动 | 负责人 | 截止日期 |
|---|---|---|---|---|---|---|---|
| | | | | | | | |
## 义务
| 包 | 许可证 | 我们必须做什么 | 完成? |
|---|---|---|---|
| | | | |
## 人工验证
- <包>:元数据说 <x>,LICENSE 文件说 <y>。
## 决策和假设
- 双重许可包及所选选项:<包:选项>
- 接受的风险及原因:<包:原因,批准人>
- 假设:<例如“管理仪表板仅内部使用”>
## 如果改变发布方式会有什么变化
- <例如:“如果我们发布本地版本,readline-sync(GPL-3.0)将成为阻碍项。”>
## 第三方通知
需要在通知文件中包含完整文本的许可证:<列表>
通知文件位置:<路径或 URL>
## 下次审计
在每次锁文件变更时在 CI 中运行;在<下次发布或日期>前进行全面审查。
_本报告是工程分类,不是法律建议。在依赖任何例外前,阻碍项必须由法律顾问确认。_
FILE:examples/example-saas-audit.md
# 示例:Node.js SaaS 后端的许可证审计
## 请求
“我们是闭源 B2B SaaS。企业客户不断发送关于开源许可证的安全问卷,明年我们可能销售本地版本。这是我们的依赖导出。有什么需要我们担心的吗?”
清单(`deps.csv`,从锁文件导出,18 行):
```
name,version,license,scope
express,4.19.2,MIT,runtime
lodash,4.17.21,MIT,runtime
pg,8.12.0,MIT,runtime
sharp,0.33.4,Apache-2.0,runtime
dompurify,3.1.6,(MPL-2.0 OR Apache-2.0),runtime
ghostscript-wrapper,2.1.0,AGPL-3.0-only,runtime
chart-kit-pro,1.4.0,SEE LICENSE IN LICENSE.md,runtime
readline-sync,1.4.10,GPL-3.0,runtime
mariadb-connector,3.3.1,LGPL-2.1-or-later,runtime
fast-csv-lite,0.9.0,,runtime
redis-om,0.4.3,Business Source License 1.1,runtime
jest,29.7.0,MIT,dev
eslint-plugin-gpl-thing,1.0.2,GPL-2.0-only,dev
argparse,2.0.1,Python-2.0,runtime
tslib,2.6.3,0BSD,runtime
lodash,3.10.1,MIT,runtime
search-client,7.10.2,Apache-2.0,runtime
search-client,8.1.0,Elastic License 2.0,runtime
```
## 脚本运行
```
$ python3 scripts/audit_licenses.py deps.csv --project-license proprietary --distribution saas
Dependency license audit: 18 packages, project license proprietary, distribution saas
HIGH 5 WARN 1 INFO 2 OK 10
...
HIGH chart-kit-pro@1.4.0 SEE LICENSE IN LICENSE.md unknown runtime
HIGH fast-csv-lite@0.9.0 (missing) unknown runtime
HIGH redis-om@0.4.3 BUSL-1.1 source-available runtime
HIGH search-client@8.1.0 Elastic-2.0 source-available runtime
HIGH ghostscript-wrapper@2.1.0 AGPL-3.0-only network-copyleft runtime
WARN readline-sync@1.4.10 GPL-3.0-only strong-copyleft runtime
INFO eslint-plugin-gpl-thing@1.0.2 GPL-2.0-only strong-copyleft dev
INFO mariadb-connector@3.3.1 LGPL-2.1-or-later weak-copyleft runtime
...
Notes:
- search-client: different licenses across versions (Apache-2.0, Elastic-2.0); check which version you ship.
```
退出码 1(有 HIGH 发现)。
## 报告(填充模板,缩短)
# 依赖许可证审计:acme-api
项目许可证:专有。分发方式:saas(客户使用 Web 应用和 REST API;今天没有安装在他们机器上的内容)。
审计包数:18(16 运行时,2 开发)。
### 摘要
- 阻碍项(HIGH):5 - 两个无可用许可证的包,两个源代码可用包,一个 AGPL 包。
- 义务(WARN):1 - 一个 GPL-3.0 包,对 SaaS 没问题,但阻碍计划的本地版本。
- 结论:修复后发布。这里没有不寻常的,每项都有便宜的修复。
### 阻碍项
| 包 | 许可证(发现时) | 对我们的重要性 | 行动 |
|---|---|---|---|
| chart-kit-pro 1.4.0 | SEE LICENSE IN LICENSE.md | LICENSE.md 是商业许可证,仅允许使用付费密钥。我们没有记录密钥。 | 与购买团队确认,或替换为宽松许可证的图表库。 |
| fast-csv-lite 0.9.0 | (missing) | 无许可证意味着无权使用。 | 请维护者添加许可证;同时切换到 MIT 条款的维护 CSV 解析器。 |
| redis-om 0.4.3 | BUSL-1.1 | 商业源代码许可证限制生产使用直到变更日期;条款因项目而异。 | 阅读确切的“附加使用授权”。如果禁止我们的使用,替换或购买许可证。 |
| search-client 8.1.0 | Elastic-2.0 | Elastic License 2.0 禁止将软件作为托管服务提供。同一树中的版本 7.10.2 是 Apache-2.0。 | 检查锁文件:哪些模块导入 8.1.0?将两者固定到 Apache-2.0 行或迁移到 Apache 许可的分支。 |
| ghostscript-wrapper 2.1.0 | AGPL-3.0-only | 我们在 API 内用它渲染客户 PDF;AGPL 网络使用要求向用户提供源代码。 | 将 PDF 渲染移到独立、未修改的工作进程,并让法律顾问确认,或购买商业许可证。 |
### 义务
| 包 | 许可证 | 我们必须做什么 |
|---|---|---|
| readline-sync 1.4.10 | GPL-3.0-only | 对 SaaS 没问题。如果本地版本随附它,则成为阻碍项。它仅由管理 CLI 脚本使用,因此从服务器镜像中移除。 |
| mariadb-connector 3.3.1 | LGPL-2.1-or-later | 保持为正常 npm 依赖(可替换);如果我们修补它,发布更改。 |
### 人工验证
- dompurify:双重许可 `MPL-2.0 OR Apache-2.0`;我们选择 Apache-2.0。
- eslint-plugin-gpl-thing:仅开发,从不发布。已接受。
### 如果发布本地版本会有什么变化
- readline-sync(GPL-3.0)变为 HIGH,mariadb-connector(LGPL)需要可替换性检查,Apache-2.0 包需要在安装程序中包含 NOTICE 文件。在该发布前使用 `--distribution distributed` 重新运行。
### 第三方通知
需要完整文本的许可证:Apache-2.0、LGPL-2.1-or-later、MIT、Python-2.0。从应用页脚链接通知页面。
_工程分类,不是法律建议。_
## 为什么这是好答案
- 它针对这家公司真实的分发模型评估每个包,并显示本地计划的变化。
- 它检查元数据无法显示的事实(商业 LICENSE.md、重复的 search-client 版本)。
- 每个阻碍项都有一个具体、便宜的行动。
FILE:scripts/audit_licenses.py
#!/usr/bin/env python3
"""根据项目的发布方式审计第三方依赖许可证。
用法:
python3 audit_licenses.py deps.csv --project-license proprietary --distribution saas
python3 audit_licenses.py licenses.json --distribution distributed [--json]
cat deps.csv | python3 audit_licenses.py - [--allow LIC,LIC] [--deny LIC,LIC]
输入(自动检测):
带表头的 CSV:name,version,license[,scope] (scope:runtime | dev;默认 runtime)
JSON 列表: [{"name": "...", "version": "...", "license": "...", "scope": "dev"}]
pip-licenses --format=json: [{"Name": "...", "Version": "...", "License": "..."}]
license-checker --json (npm): {"pkg@1.2.3": {"licenses": "MIT"}, ...}
选项:
--project-license 你的项目许可证:proprietary(默认)或 SPDX ID,如 MIT、Apache-2.0、GPL-3.0-only
--distribution saas(网络服务,默认)| distributed(应用、二进制、设备、库)| internal
--allow / --deny 逗号分隔的 SPDX ID,你的政策始终允许或始终禁止
--json 机器可读输出
许可证名称规范化为 SPDX ID,OR 表达式取最宽松选项,AND 取最严格选项。每个包根据所选分发获得类别和严重性(OK、INFO、WARN、HIGH)。仅开发包不发布,因此其发现降级为 INFO。
退出码:0 无 HIGH 发现,1 至少一个 HIGH,2 用法或输入错误。
这是工程分类辅助,不是法律建议。仅标准库。
"""
import argparse
import csv
import io
import json
import re
import sys
ALIASES = { # 小写常见名称 -> SPDX ID
"mit": "MIT", "mit license": "MIT", "the mit license": "MIT", "expat": "MIT",
"isc": "ISC", "isc license": "ISC", "isc license (iscl)": "ISC",
"apache 2.0": "Apache-2.0", "apache-2": "Apache-2.0", "apache 2": "Apache-2.0", "apache2": "Apache-2.0",
"apache license 2.0": "Apache-2.0", "apache license, version 2.0": "Apache-2.0",
"apache software license": "Apache-2.0", "apache license version 2.0": "Apache-2.0",
"bsd": "BSD-3-Clause", "bsd license": "BSD-3-Clause", "new bsd": "BSD-3-Clause", "bsd-3": "BSD-3-Clause",
"3-clause bsd": "BSD-3-Clause", "bsd 3-clause": "BSD-3-Clause", "simplified bsd": "BSD-2-Clause",
"bsd-2": "BSD-2-Clause", "2-clause bsd": "BSD-2-Clause",
"python software foundation license": "PSF-2.0", "psf": "PSF-2.0", "psfl": "PSF-2.0",
"mozilla public license 2.0 (mpl 2.0)": "MPL-2.0", "mpl 2.0": "MPL-2.0", "mpl-2": "MPL-2.0", "mpl2": "MPL-2.0",
"gplv2": "GPL-2.0-only", "gpl-2.0": "GPL-2.0-only", "gpl v2": "GPL-2.0-only", "gpl2": "GPL-2.0-only",
"gplv2+": "GPL-2.0-or-later", "gpl-2.0+": "GPL-2.0-or-later",
"gplv3": "GPL-3.0-only", "gpl-3.0": "GPL-3.0-only", "gpl v3": "GPL-3.0-only", "gpl3": "GPL-3.0-only",
"gplv3+": "GPL-3.0-or-later", "gpl-3.0+": "GPL-3.0-or-later", "gpl": "GPL-3.0-or-later",
"gnu general public license v2 (gplv2)": "GPL-2.0-only", "gnu general public license v3 (gplv3)": "GPL-3.0-only",
"gnu general public license v2 or later (gplv2+)": "GPL-2.0-or-later",
"gnu general public license v3 or later (gplv3+)": "GPL-3.0-or-later",
"lgpl": "LGPL-2.1-or-later", "lgplv2": "LGPL-2.1-only", "lgplv2+": "LGPL-2.1-or-later", "lgpl-2.1": "LGPL-2.1-only",
"lgplv3": "LGPL-3.0-only", "lgplv3+": "LGPL-3.0-or-later", "lgpl-3.0": "LGPL-3.0-only",
"gnu lesser general public license v2 (lgplv2)": "LGPL-2.1-only",
"gnu lesser general public license v3 (lgplv3)": "LGPL-3.0-only",
"gnu lesser general public license v2 or later (lgplv2+)": "LGPL-2.1-or-later",
"agpl": "AGPL-3.0-only", "agplv3": "AGPL-3.0-only", "agpl-3.0": "AGPL-3.0-only",
"gnu affero general public license v3": "AGPL-3.0-only", "gnu affero general public license v3 (agplv3)": "AGPL-3.0-only",
"eclipse public license 2.0": "EPL-2.0", "epl 2.0": "EPL-2.0", "epl-2": "EPL-2.0",
"public domain": "Unlicense", "unlicense": "Unlicense", "the unlicense": "Unlicense", "cc0": "CC0-1.0",
"boost software license 1.0": "BSL-1.0", "zlib license": "Zlib", "zlib": "Zlib",
"business source license 1.1": "BUSL-1.1", "elastic license 2.0": "Elastic-2.0",
"server side public license": "SSPL-1.0", "sspl": "SSPL-1.0",
}
CATEGORY = { # SPDX ID 前缀 -> 类别
"MIT": "permissive", "MIT-0": "public-domain", "ISC": "permissive", "BSD-2-Clause": "permissive",
"BSD-3-Clause": "permissive", "Apache-2.0": "permissive", "PSF-2.0": "permissive", "Python-2.0": "permissive",
"Zlib": "permissive", "BSL-1.0": "permissive", "X11": "permissive", "BlueOak-1.0.0": "permissive",
"CC-BY-4.0": "permissive", "CC-BY-3.0": "permissive", "Unicode-DFS-2016": "permissive", "Unicode-3.0": "permissive",
"0BSD": "public-domain", "Unlicense": "public-domain", "CC0-1.0": "public-domain", "WTFPL": "public-domain",
"MPL-2.0": "weak-copyleft", "EPL-1.0": "weak-copyleft", "EPL-2.0": "weak-copyleft", "CDDL-1.0": "weak-copyleft",
"CDDL-1.1": "weak-copyleft", "LGPL-2.1": "weak-copyleft", "LGPL-3.0": "weak-copyleft", "CC-BY-SA-4.0": "weak-copyleft",
"GPL-2.0": "strong-copyleft", "GPL-3.0": "strong-copyleft",
"AGPL-3.0": "network-copyleft", "SSPL-1.0": "network-copyleft",
"BUSL-1.1": "source-available", "Elastic-2.0": "source-available", "Commons-Clause": "source-available",
"CC-BY-NC-4.0": "source-available", "CC-BY-NC-SA-4.0": "source-available", "PolyForm-Noncommercial-1.0.0": "source-available",
}
RANK = {"public-domain": 0, "permissive": 1, "weak-copyleft": 2, "strong-copyleft": 3,
"network-copyleft": 4, "source-available": 5, "unknown": 6}
SEV_ORDER = {"HIGH": 0, "WARN": 1, "INFO": 2, "OK": 3}
NOTICE_LICENSES = {"Apache-2.0"}
def unwrap(text):
"""移除包裹整个表达式的括号:'(MIT OR X)' -> 'MIT OR X'。"""
t = text.strip()
while t.startswith("(") and t.endswith(")"):
depth = 0
for i, ch in enumerate(t):
depth += ch == "("
depth -= ch == ")"
if depth == 0 and i < len(t) - 1:
return t
t = t[1:-1].strip()
return t
def normalize(token):
t = unwrap(token)
if not t:
return ""
key = t.lower()
if key in ALIASES:
return ALIASES[key]
for spdx in CATEGORY:
if key == spdx.lower() or key.startswith(spdx.lower() + "-") or key == spdx.lower() + "+":
return t if key.startswith(spdx.lower() + "-") else spdx
return t
def category_of(spdx):
if spdx.upper().startswith("LICENSEREF") or spdx.upper().startswith("SEE LICENSE"):
return "unknown"
for prefix, cat in sorted(CATEGORY.items(), key=lambda kv: -len(kv[0])):
if spdx == prefix or spdx.startswith(prefix + "-") or spdx.startswith(prefix + "+"):
return cat
return "unknown"
def classify(expr):
"""返回(规范化表达式、所选许可证、类别、例外)用于许可证表达式。"""
raw = (expr or "").strip()
if not raw or raw.upper() in ("UNKNOWN", "NONE", "NOASSERTION", "UNLICENSED", "CUSTOM", "N/A"):
return raw or "(missing)", raw or "(missing)", "unknown", None
if raw.upper().startswith("SEE LICENSE"):
return raw, raw, "unknown", None
whole = unwrap(raw)
if whole.lower() in ALIASES: # 长名称如 "... v2 or later (LGPLv2+)" 是单个许可证
spdx = ALIASES[whole.lower()]
return spdx, spdx, category_of(spdx), None
text = re.sub(r"\s+", " ", whole.replace("/", " OR ").replace(";", " OR "))
exception = None
m = re.search(r"\bWITH\s+([\w.+-]+)", text, re.I)
if m:
exception = m.group(1)
text = text[:m.start()] + text[m.end():]
if re.search(r"\sor\s", text, re.I) and not re.search(r"\sand\s", text, re.I):
parts, mode = re.split(r"\s+or\s+", text, flags=re.I), "or"
elif re.search(r"\sand\s", text, re.I) and not re.search(r"\sor\s", text, re.I):
parts, mode = re.split(r"\s+and\s+", text, flags=re.I), "and"
elif re.search(r"\s(or|and)\s", text, re.I):
parts, mode = re.split(r"\s+(?:or|and)\s+", text, flags=re.I), "and" # 混合:保守处理
else:
parts, mode = [text], "single"
lics = [normalize(p) for p in parts if normalize(p)]
if not lics:
return raw, raw, "unknown", exception
scored = sorted(lics, key=lambda l: RANK[category_of(l)])
chosen = scored[0] if mode == "or" else scored[-1]
cat = category_of(chosen)
if exception and cat == "strong-copyleft" and re.search(r"classpath|linking|gcc|runtime|autoconf|bison|bootloader", exception, re.I):
cat = "weak-copyleft"
joiner = " OR " if mode == "or" else " AND "
return joiner.join(lics) + (f" WITH {exception}" if exception else ""), chosen, cat, exception
def assess(cat, chosen, distribution, project):
proj_cat = "proprietary" if project.lower() == "proprietary" else category_of(normalize(project))
project_spdx = normalize(project)
if cat == "unknown":
return "HIGH", "许可证缺失或无法识别;发布前找到真实许可证文本。"
if cat == "source-available":
return "HIGH", "源代码可用或非商业条款通常限制商业使用或托管;需要许可证审查。"
if project_spdx.startswith("GPL-2.0-only") and chosen.startswith("Apache-2.0"):
return "HIGH", "Apache-2.0 通常被认为与 GPL-2.0-only 项目不兼容。"
gpl3_family = re.match(r"(A|L)?GPL-3\.0", chosen)
if project_spdx.startswith("GPL-2.0-only") and gpl3_family:
return "HIGH", f"{chosen} 不能与 GPL-2.0-only 项目合并;项目需要 GPL-3.0 条款。"
if re.match(r"GPL-3\.0|AGPL-3\.0", project_spdx) and chosen.startswith("GPL-2.0-only"):
return "HIGH", "GPL-2.0-only 代码不能在 GPL-3.0 下重新许可;请上游提供“或更高版本”条款或替换它。"
if proj_cat in ("strong-copyleft", "network-copyleft") and cat in ("strong-copyleft", "network-copyleft"):
if cat == "network-copyleft" and proj_cat != "network-copyleft" and distribution == "saas":
return "WARN", "GPL 项目中作为服务运行的 AGPL/SSPL:必须向用户提供整个服务的源代码。"
return "OK", "Copyleft 项目中的 Copyleft 依赖;检查版本兼容性(例如 GPL-2.0-only 与 GPL-3.0)。"
if cat == "network-copyleft":
if distribution == "internal":
return "WARN", "网络 Copyleft:纯内部使用没问题,但暴露给外部用户会触发源代码义务。"
return "HIGH", "网络 Copyleft(AGPL/SSPL):提供服务或发布它要求发布相应源代码。"
if cat == "strong-copyleft":
if distribution == "distributed":
return "HIGH", "GPL 代码随产品分发要求合并作品以 GPL 发布。"
if distribution == "saas":
return "WARN", "GPL 不因网络使用而触发,但任何未来分发(应用、本地、SDK)都会。"
return "INFO", "仅内部使用的 GPL 无分发义务;保持它不进入任何发布内容。"
if cat == "weak-copyleft":
if distribution == "distributed":
return "WARN", "弱 Copyleft:发布许可证,保持库可替换(LGPL 动态链接),并发布对其文件的更改。"
return "INFO", "弱 Copyleft:如果稍后分发,发布你对这个库自身文件的任何更改。"
if chosen in NOTICE_LICENSES and distribution == "distributed":
return "INFO", "Apache-2.0:在你的分发中包含许可证和任何 NOTICE 文件。"
return "OK", "宽松:在第三方通知中保留版权声明和许可证文本。"
def load(text):
stripped = text.lstrip()
if not stripped:
raise ValueError("输入为空")
if stripped[0] in "[{":
data = json.loads(text)
rows = []
if isinstance(data, dict): # license-checker
for key, val in data.items():
name, _, ver = key.rpartition("@")
if not name:
name, ver = key, ""
lic = val.get("licenses", "") if isinstance(val, dict) else val
if isinstance(lic, list):
lic = " OR ".join(lic)
rows.append({"name": name, "version": ver, "license": str(lic), "scope": "runtime"})
return rows
for item in data:
if not isinstance(item, dict):
raise ValueError("JSON 列表项必须是对象")
low = {k.lower(): v for k, v in item.items()}
lic = low.get("license", low.get("licenses", ""))
if isinstance(lic, list):
lic = " OR ".join(lic)
rows.append({"name": str(low.get("name", "")), "version": str(low.get("version", "")),
"license": str(lic or ""), "scope": str(low.get("scope", "runtime") or "runtime").lower()})
return rows
reader = csv.DictReader(io.StringIO(text))
fields = [f.strip().lower() for f in (reader.fieldnames or [])]
if "name" not in fields or "license" not in fields:
raise ValueError(f"CSV 需要至少包含 'name' 和 'license' 列的表头,得到:{', '.join(fields) or '(none)'}")
rows = []
for r in reader:
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
if not r.get("name"):
continue
rows.append({"name": r["name"], "version": r.get("version", ""), "license": r.get("license", ""),
"scope": (r.get("scope") or "runtime").lower()})
return rows
def audit(rows, args):
allow = {normalize(x) for x in args.allow.split(",") if x.strip()} if args.allow else set()
deny = {normalize(x) for x in args.deny.split(",") if x.strip()} if args.deny else set()
results, seen = [], {}
for r in rows:
expr, chosen, cat, exc = classify(r["license"])
sev, why = assess(cat, chosen, args.distribution, args.project_license)
if exc:
why += f" 许可证例外 {exc} 适用;阅读其确切范围。"
if chosen in deny:
sev, why = "HIGH", f"{chosen} 在你的禁止列表中。"
elif chosen in allow and sev != "OK":
sev, why = "OK", f"{chosen} 在你的允许列表中({why})"
dev = r["scope"] in ("dev", "development", "test", "build")
if dev and sev in ("HIGH", "WARN") and chosen not in deny:
sev, why = "INFO", "仅开发(不发布):" + why
results.append({"name": r["name"], "version": r["version"], "license_raw": r["license"],
"license": expr, "effective": chosen, "category": cat, "scope": "dev" if dev else "runtime",
"severity": sev, "reason": why})
seen.setdefault(r["name"].lower(), set()).add(expr)
notes = []
for name, lics in seen.items():
if len(lics) > 1:
notes.append(f"{name}:不同版本许可证不同({', '.join(sorted(lics))});检查你发布的版本。")
results.sort(key=lambda x: (SEV_ORDER[x["severity"]], -RANK[x["category"]], x["name"].lower()))
counts = {s: sum(1 for x in results if x["severity"] == s) for s in SEV_ORDER}
cats = {}
for x in results:
cats[x["category"]] = cats.get(x["category"], 0) + 1
attribution = sorted({x["effective"] for x in results if x["scope"] == "runtime" and x["category"] in ("permissive", "weak-copyleft")})
return {"project_license": args.project_license, "distribution": args.distribution, "packages": len(results),
"severity_counts": counts, "category_counts": cats, "attribution_licenses": attribution,
"notes": notes, "results": results}
def print_text(rep):
print(f"依赖许可证审计:{rep['packages']} 个包,项目许可证 {rep['project_license']},分发方式 {rep['distribution']}")
c = rep["severity_counts"]
print(f"HIGH {c['HIGH']} WARN {c['WARN']} INFO {c['INFO']} OK {c['OK']}")
print("类别:" + ", ".join(f"{k} {v}" for k, v in sorted(rep["category_counts"].items(), key=lambda kv: RANK[kv[0]])))
print()
w = max([len(f"{x['name']}@{x['version']}") for x in rep["results"]] + [7])
print(f"{'SEV':<5} {'PACKAGE':<{w}} {'LICENSE':<28} {'CATEGORY':<17} SCOPE")
for x in rep["results"]:
pkg = f"{x['name']}@{x['version']}" if x["version"] else x["name"]
print(f"{x['severity']:<5} {pkg:<{w}} {x['license'][:28]:<28} {x['category']:<17} {x['scope']}")
findings = [x for x in rep["results"] if x["severity"] in ("HIGH", "WARN", "INFO")]
if findings:
print("\n发现:")
for x in findings:
print(f"- [{x['severity']}] {x['name']}({x['license_raw'] or '无许可证字段'}):{x['reason']}")
if rep["notes"]:
print("\n备注:")
for n in rep["notes"]:
print(f"- {n}")
if rep["attribution_licenses"]:
print("\n第三方通知必须包含许可证文本: " + ", ".join(rep["attribution_licenses"]))
print("\n仅分类辅助,不是法律建议。")
def main():
p = argparse.ArgumentParser(description="审计依赖许可证。", add_help=True)
p.add_argument("path", help="CSV 或 JSON 文件,或 - 用于标准输入")
p.add_argument("--project-license", default="proprietary")
p.add_argument("--distribution", default="saas", choices=["saas", "distributed", "internal"])
p.add_argument("--allow", default="")
p.add_argument("--deny", default="")
p.add_argument("--json", action="store_true")
args = p.parse_args()
try:
text = sys.stdin.read() if args.path == "-" else open(args.path, encoding="utf-8").read()
rows = load(text)
except FileNotFoundError:
print(f"错误:文件未找到:{args.path}", file=sys.stderr)
return 2
except (ValueError, json.JSONDecodeError) as e:
print(f"错误:{e}", file=sys.stderr)
return 2
if not rows:
print("错误:输入中未找到依赖项", file=sys.stderr)
return 2
rep = audit(rows, args)
if args.json:
print(json.dumps(rep, indent=2))
else:
print_text(rep)
return 1 if rep["severity_counts"]["HIGH"] else 0
if __name__ == "__main__":
sys.exit(main())
用法
此提示词专为 coding 设计。复制上方内容并粘贴到你常用的 AI 工具中。
为获得最佳效果,可将占位符(方括号或大写字母标示)替换为你的具体需求。
讨论
0 条评论