diff --git a/README.md b/README.md index 4d410e11..56267b4c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — License Policy Gate](#whats-new-2026-06-21--license-policy-gate) - [What's new (2026-06-21) — OpenVEX Vulnerability Triage](#whats-new-2026-06-21--openvex-vulnerability-triage) - [What's new (2026-06-21) — Dependency Vulnerability Scanning (OSV)](#whats-new-2026-06-21--dependency-vulnerability-scanning-osv) - [What's new (2026-06-21) — JSON Schema Validation](#whats-new-2026-06-21--json-schema-validation) @@ -112,6 +113,12 @@ --- +## What's new (2026-06-21) — License Policy Gate + +Flag disallowed dependency licenses. Full reference: [`docs/source/Eng/doc/new_features/v60_features_doc.rst`](docs/source/Eng/doc/new_features/v60_features_doc.rst). + +- **`evaluate_sbom` / `evaluate_license` / `normalize_spdx` / `license_findings_to_sarif`** (`AC_check_licenses`, `ac_check_licenses`): the SBOM recorded each dependency's license name but never *judged* it. This normalizes license strings to SPDX ids and evaluates them against an allowlist/denylist (with a built-in `DEFAULT_COPYLEFT` set), understanding SPDX expressions (`OR` = choice, `AND` = all), then bridges violations into SARIF (`denied`→error, `unknown`→warning). Pure-stdlib, fully offline — the license-compliance lane beside the OSV vulnerability lane. + ## What's new (2026-06-21) — OpenVEX Vulnerability Triage Suppress the vulns that don't affect you. Full reference: [`docs/source/Eng/doc/new_features/v59_features_doc.rst`](docs/source/Eng/doc/new_features/v59_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index e05834c1..dbffe25d 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — 许可证策略闸门](#本次更新-2026-06-21--许可证策略闸门) - [本次更新 (2026-06-21) — OpenVEX 漏洞分级](#本次更新-2026-06-21--openvex-漏洞分级) - [本次更新 (2026-06-21) — 依赖项漏洞扫描(OSV)](#本次更新-2026-06-21--依赖项漏洞扫描osv) - [本次更新 (2026-06-21) — JSON Schema 验证](#本次更新-2026-06-21--json-schema-验证) @@ -111,6 +112,12 @@ --- +## 本次更新 (2026-06-21) — 许可证策略闸门 + +标记不被允许的依赖项许可证。完整参考:[`docs/source/Zh/doc/new_features/v60_features_doc.rst`](../docs/source/Zh/doc/new_features/v60_features_doc.rst)。 + +- **`evaluate_sbom` / `evaluate_license` / `normalize_spdx` / `license_findings_to_sarif`**(`AC_check_licenses`、`ac_check_licenses`):SBOM 记录了每个依赖项的许可证名称却从未*判断*它。本功能把许可证字符串规范化为 SPDX id,以允许列表/拒绝列表(内建 `DEFAULT_COPYLEFT` 集合)评估,理解 SPDX 表达式(`OR` = 择一、`AND` = 全部),再把违规桥接到 SARIF(`denied`→error、`unknown`→warning)。纯标准库、完全离线 —— 与 OSV 漏洞通道并列的许可证合规通道。 + ## 本次更新 (2026-06-21) — OpenVEX 漏洞分级 抑制不影响你的漏洞。完整参考:[`docs/source/Zh/doc/new_features/v59_features_doc.rst`](../docs/source/Zh/doc/new_features/v59_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index cc5ea0bf..18725bad 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — 授權政策閘門](#本次更新-2026-06-21--授權政策閘門) - [本次更新 (2026-06-21) — OpenVEX 漏洞分級](#本次更新-2026-06-21--openvex-漏洞分級) - [本次更新 (2026-06-21) — 相依套件漏洞掃描(OSV)](#本次更新-2026-06-21--相依套件漏洞掃描osv) - [本次更新 (2026-06-21) — JSON Schema 驗證](#本次更新-2026-06-21--json-schema-驗證) @@ -111,6 +112,12 @@ --- +## 本次更新 (2026-06-21) — 授權政策閘門 + +標記不被允許的相依套件授權。完整參考:[`docs/source/Zh/doc/new_features/v60_features_doc.rst`](../docs/source/Zh/doc/new_features/v60_features_doc.rst)。 + +- **`evaluate_sbom` / `evaluate_license` / `normalize_spdx` / `license_findings_to_sarif`**(`AC_check_licenses`、`ac_check_licenses`):SBOM 記錄了每個相依套件的授權名稱卻從未*判斷*它。本功能把授權字串正規化為 SPDX id,以允許清單/拒絕清單(內建 `DEFAULT_COPYLEFT` 集合)評估,理解 SPDX 運算式(`OR` = 擇一、`AND` = 全部),再把違規橋接到 SARIF(`denied`→error、`unknown`→warning)。純標準函式庫、完全離線 —— 與 OSV 漏洞通道並列的授權合規通道。 + ## 本次更新 (2026-06-21) — OpenVEX 漏洞分級 抑制不影響你的漏洞。完整參考:[`docs/source/Zh/doc/new_features/v59_features_doc.rst`](../docs/source/Zh/doc/new_features/v59_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v60_features_doc.rst b/docs/source/Eng/doc/new_features/v60_features_doc.rst new file mode 100644 index 00000000..eedba0f9 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v60_features_doc.rst @@ -0,0 +1,49 @@ +License Policy Gate +=================== + +``build_sbom`` records each component's license *name*, but nothing ever judged +it — a copyleft or otherwise-disallowed license could ship unnoticed. This adds +the policy gate: normalize the SBOM's license strings to SPDX ids, evaluate them +against an allowlist / denylist (with a built-in strong-copyleft set), and emit +violations that bridge into the existing SARIF exporter — the license-compliance +lane beside the OSV vulnerability lane. + +Pure standard library (``re``); fully offline; imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + build_sbom, evaluate_sbom, evaluate_license, + license_findings_to_sarif, write_sarif, DEFAULT_COPYLEFT) + + sbom = build_sbom("je_auto_control") + + # Allowlist mode: anything outside the list is a violation. + violations = evaluate_sbom(sbom["components"], + allow=["MIT", "Apache-2.0", "BSD-3-Clause"]) + + # Or denylist mode using the built-in strong-copyleft set. + violations = evaluate_sbom(sbom["components"], deny=DEFAULT_COPYLEFT) + + write_sarif(license_findings_to_sarif(violations), "licenses.sarif", + tool_name="AutoControl-License") + +``normalize_spdx`` maps loose names (``"MIT License"`` → ``MIT``, ``"Apache +2.0"`` → ``Apache-2.0``) to SPDX ids. ``evaluate_license`` returns ``allowed`` / +``denied`` / ``unknown``: ``deny`` takes precedence; an empty ``allow`` means +"not constrained"; a missing license is ``unknown``. SPDX **expressions** are +understood — ``"MIT OR GPL-3.0-only"`` is a *choice* (allowed if any operand is +allowed), while ``"MIT AND GPL-3.0-only"`` requires every operand. Each +violation is ``{name, version, license, status}``; ``denied`` maps to a SARIF +``error`` and ``unknown`` to a ``warning``. + +Executor command +---------------- + +``AC_check_licenses`` takes ``components`` (a component list, a full SBOM dict, +or a JSON string) and optional ``allow`` / ``deny`` lists; it returns +``{violations, count}``. The same operation is exposed as the MCP tool +``ac_check_licenses`` and as a Script Builder command under **Security**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index d9727e50..1fcdb577 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -82,6 +82,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v57_features_doc doc/new_features/v58_features_doc doc/new_features/v59_features_doc + doc/new_features/v60_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v60_features_doc.rst b/docs/source/Zh/doc/new_features/v60_features_doc.rst new file mode 100644 index 00000000..4be9fe69 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v60_features_doc.rst @@ -0,0 +1,45 @@ +授權政策閘門 +=========== + +``build_sbom`` 會記錄每個元件的授權*名稱*,但從未對它做判斷 —— copyleft 或其他不被允許的授權 +可能在無人察覺下隨產品出貨。本功能補上政策閘門:把 SBOM 的授權字串正規化為 SPDX id,以 +允許清單 / 拒絕清單(內建強 copyleft 集合)評估,並產生可橋接到既有 SARIF 匯出器的違規項目 +—— 與 OSV 漏洞通道並列的授權合規通道。 + +純標準函式庫(``re``);完全離線;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + build_sbom, evaluate_sbom, evaluate_license, + license_findings_to_sarif, write_sarif, DEFAULT_COPYLEFT) + + sbom = build_sbom("je_auto_control") + + # 允許清單模式:清單之外的一律視為違規。 + violations = evaluate_sbom(sbom["components"], + allow=["MIT", "Apache-2.0", "BSD-3-Clause"]) + + # 或以內建強 copyleft 集合做拒絕清單模式。 + violations = evaluate_sbom(sbom["components"], deny=DEFAULT_COPYLEFT) + + write_sarif(license_findings_to_sarif(violations), "licenses.sarif", + tool_name="AutoControl-License") + +``normalize_spdx`` 把寬鬆的名稱(``"MIT License"`` → ``MIT``、``"Apache 2.0"`` → +``Apache-2.0``)對應到 SPDX id。``evaluate_license`` 回傳 ``allowed`` / ``denied`` / +``unknown``:``deny`` 優先;空的 ``allow`` 代表「不受限制」;缺少授權則為 ``unknown``。亦理解 +SPDX **運算式** —— ``"MIT OR GPL-3.0-only"`` 是*擇一*(任一運算元被允許即允許),而 +``"MIT AND GPL-3.0-only"`` 需要每個運算元都被允許。每個違規為 +``{name, version, license, status}``;``denied`` 對應 SARIF ``error``,``unknown`` 對應 +``warning``。 + +執行器命令 +---------- + +``AC_check_licenses`` 接受 ``components``(元件清單、完整 SBOM dict 或 JSON 字串)與選用的 +``allow`` / ``deny`` 清單;回傳 ``{violations, count}``。同一操作亦以 MCP 工具 +``ac_check_licenses`` 以及 Script Builder 中 **Security** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 62c3eb5a..0d514604 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -82,6 +82,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v57_features_doc doc/new_features/v58_features_doc doc/new_features/v59_features_doc + doc/new_features/v60_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index ee863902..ffd26b02 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -310,6 +310,11 @@ from je_auto_control.utils.vex import ( VEX_JUSTIFICATIONS, VEX_STATUSES, apply_vex, build_vex, vex_statement, ) +# SPDX license allow/deny policy over SBOM components +from je_auto_control.utils.license_policy import ( + DEFAULT_COPYLEFT, evaluate_license, evaluate_sbom, + license_findings_to_sarif, normalize_spdx, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -784,6 +789,8 @@ def start_autocontrol_gui(*args, **kwargs): "version_key", "VEX_JUSTIFICATIONS", "VEX_STATUSES", "apply_vex", "build_vex", "vex_statement", + "DEFAULT_COPYLEFT", "evaluate_license", "evaluate_sbom", + "license_findings_to_sarif", "normalize_spdx", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 77b3a829..63f4d069 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1179,6 +1179,19 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Suppress not_affected/fixed vulns via an OpenVEX document.", )) + specs.append(CommandSpec( + "AC_check_licenses", "Security", "Check Dependency Licenses", + fields=( + FieldSpec("components", FieldType.STRING, + placeholder='{"components": [{"name": "x", ' + '"licenses": [{"license": {"name": "MIT"}}]}]}'), + FieldSpec("allow", FieldType.STRING, optional=True, + placeholder='["MIT", "Apache-2.0", "BSD-3-Clause"]'), + FieldSpec("deny", FieldType.STRING, optional=True, + placeholder='["GPL-3.0-only", "AGPL-3.0-only"]'), + ), + description="Evaluate SBOM licenses against allow/deny SPDX lists.", + )) specs.append(CommandSpec( "AC_run_saga", "Flow", "Run Saga (Compensating Rollback)", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 1ff26c26..3f7c4564 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2897,6 +2897,23 @@ def _apply_vex(findings: Any, vex: Any) -> Dict[str, Any]: return {"findings": kept, "count": len(kept)} +def _check_licenses(components: Any, allow: Any = None, + deny: Any = None) -> Dict[str, Any]: + """Adapter: evaluate SBOM component licenses against allow/deny lists.""" + import json + from je_auto_control.utils.license_policy import evaluate_sbom + if isinstance(components, str): + components = json.loads(components) + if isinstance(components, dict): + components = components.get("components", []) + if isinstance(allow, str): + allow = json.loads(allow) + if isinstance(deny, str): + deny = json.loads(deny) + violations = evaluate_sbom(components, allow=allow, deny=deny) + return {"violations": violations, "count": len(violations)} + + def _generate_sop(actions: List[Any], title: str = "Automation Procedure", path: Optional[str] = None) -> Dict[str, Any]: """Adapter: build (or write) a step-by-step SOP from an action list.""" @@ -3678,6 +3695,7 @@ def __init__(self): "AC_scan_secrets": _scan_secrets, "AC_scan_vulns": _scan_vulns, "AC_apply_vex": _apply_vex, + "AC_check_licenses": _check_licenses, "AC_generate_sop": _generate_sop, "AC_tween_drag": _tween_drag, "AC_list_plugins": _list_plugins, diff --git a/je_auto_control/utils/license_policy/__init__.py b/je_auto_control/utils/license_policy/__init__.py new file mode 100644 index 00000000..8458c669 --- /dev/null +++ b/je_auto_control/utils/license_policy/__init__.py @@ -0,0 +1,10 @@ +"""SPDX license allow/deny policy evaluation over SBOM components.""" +from je_auto_control.utils.license_policy.license_policy import ( + DEFAULT_COPYLEFT, evaluate_license, evaluate_sbom, + license_findings_to_sarif, normalize_spdx, +) + +__all__ = [ + "DEFAULT_COPYLEFT", "evaluate_license", "evaluate_sbom", + "license_findings_to_sarif", "normalize_spdx", +] diff --git a/je_auto_control/utils/license_policy/license_policy.py b/je_auto_control/utils/license_policy/license_policy.py new file mode 100644 index 00000000..61071276 --- /dev/null +++ b/je_auto_control/utils/license_policy/license_policy.py @@ -0,0 +1,129 @@ +"""Evaluate dependency licenses against an allow / deny policy. + +``utils/sbom`` records each component's license *name* but never judges it, so +a copyleft or otherwise-disallowed license could ship unnoticed. This adds the +policy gate: normalize the SBOM's license strings to SPDX ids, evaluate them +against an allowlist / denylist (with a built-in strong-copyleft set), and emit +violations that bridge into the existing SARIF exporter — the license-compliance +lane beside the OSV vulnerability lane. + +Pure standard library (``re``); imports no ``PySide6``. +""" +import re +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set + +from je_auto_control.utils.sarif import make_finding + +# Strong/network copyleft SPDX ids most policies want to flag. +DEFAULT_COPYLEFT = frozenset({ + "GPL-2.0-only", "GPL-2.0-or-later", "GPL-3.0-only", "GPL-3.0-or-later", + "AGPL-3.0-only", "AGPL-3.0-or-later", "LGPL-2.1-only", "LGPL-3.0-only", + "LGPL-3.0-or-later", "MPL-2.0", "EPL-2.0", "CDDL-1.0", +}) + +# Canonical SPDX id -> the loose names that should normalize to it. Inverted +# below so each SPDX id literal appears exactly once (no duplicated literals). +_ALIAS_GROUPS = { + "MIT": ("mit license", "the mit license", "mit"), + "Apache-2.0": ("apache 2.0", "apache-2", "apache 2", "apache2", + "apache software license", "apache license 2.0"), + "BSD-3-Clause": ("bsd", "bsd license", "new bsd license"), + "GPL-2.0-only": ("gplv2",), + "GPL-3.0-only": ("gplv3", "gnu gplv3"), + "LGPL-3.0-only": ("lgplv3",), + "MPL-2.0": ("mpl 2.0",), + "ISC": ("isc license",), +} +_ALIASES = {alias: spdx for spdx, names in _ALIAS_GROUPS.items() + for alias in names} + +_OPERATOR_RE = re.compile(r"\b(?:OR|AND|WITH)\b|[()]", re.IGNORECASE) + + +def normalize_spdx(raw: str) -> str: + """Normalize a single license token to a canonical SPDX id (best effort).""" + text = str(raw).strip() + if not text: + return "" + alias = _ALIASES.get(text.lower()) + if alias: + return alias + lowered = text.lower() + for suffix in (" license", " licence"): + if lowered.endswith(suffix): + return text[:-len(suffix)].strip() + return text + + +def _extract_ids(license_str: str) -> List[str]: + parts = _OPERATOR_RE.split(str(license_str)) + return [spdx for spdx in (normalize_spdx(part) for part in parts) if spdx] + + +def _norm_set(values: Optional[Sequence[str]]) -> Set[str]: + return {normalize_spdx(value) for value in values} if values else set() + + +def _allow_status(ids: Sequence[str], allow: Sequence[str], + license_str: str) -> str: + allow_set = _norm_set(allow) + matcher = any if " or " in f" {str(license_str).lower()} " else all + return "allowed" if matcher(spdx in allow_set for spdx in ids) else "denied" + + +def evaluate_license(license_str: str, *, + allow: Optional[Sequence[str]] = None, + deny: Optional[Sequence[str]] = None) -> str: + """Return ``allowed`` / ``denied`` / ``unknown`` for a license string.""" + ids = _extract_ids(license_str) + if not ids: + return "unknown" + deny_set = _norm_set(deny) + if deny_set and any(spdx in deny_set for spdx in ids): + return "denied" + if allow is None: + return "allowed" + return _allow_status(ids, allow, license_str) + + +def _component_license(component: Mapping[str, Any]) -> str: + for entry in component.get("licenses", []): + if "expression" in entry: + return str(entry["expression"]) + license_obj = entry.get("license", {}) + name = license_obj.get("id") or license_obj.get("name") + if name: + return str(name) + return "" + + +def evaluate_sbom(components: Sequence[Mapping[str, Any]], *, + allow: Optional[Sequence[str]] = None, + deny: Optional[Sequence[str]] = None) -> List[Dict[str, Any]]: + """Return a violation per component whose license is not ``allowed``.""" + violations: List[Dict[str, Any]] = [] + for component in components: + license_str = _component_license(component) + status = evaluate_license(license_str, allow=allow, deny=deny) + if status != "allowed": + violations.append({ + "name": str(component.get("name", "")), + "version": str(component.get("version", "")), + "license": license_str, + "status": status, + }) + return violations + + +def license_findings_to_sarif(violations: Sequence[Mapping[str, Any]] + ) -> List[Dict[str, Any]]: + """Convert license violations into SARIF-ready normalized findings.""" + findings = [] + for violation in violations: + level = "error" if violation["status"] == "denied" else "warning" + shown = violation["license"] or "unknown" + message = (f"{violation['name']} {violation['version']}: license " + f"'{shown}' is {violation['status']}") + findings.append(make_finding( + f"license/{violation['name']}", message, level=level)) + return findings diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 17a184af..f39dc213 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3279,6 +3279,24 @@ def vex_tools() -> List[MCPTool]: ] +def license_policy_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_check_licenses", + description=("Evaluate SBOM 'components' (or a full SBOM dict) " + "licenses against 'allow'/'deny' SPDX lists. Returns " + "{violations:[{name, version, license, status}], " + "count} where status is denied/unknown."), + input_schema=schema( + {"components": {"type": "object"}, + "allow": {"type": "array"}, "deny": {"type": "array"}}, + ["components"]), + handler=h.check_licenses, + annotations=READ_ONLY, + ), + ] + + def saga_tools() -> List[MCPTool]: return [ MCPTool( @@ -4473,7 +4491,8 @@ def media_assert_tools() -> List[MCPTool]: video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, locale_tools, voice_tools, coordinate_space_tools, loop_guard_tools, process_mining_tools, asset_tools, events_tools, notify_channel_tools, - jsonpath_tools, json_schema_tools, vuln_scan_tools, vex_tools, saga_tools, + jsonpath_tools, json_schema_tools, vuln_scan_tools, vex_tools, + license_policy_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index aa24e88b..224c4c4c 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1575,6 +1575,14 @@ def apply_vex(findings, vex): return {"findings": kept, "count": len(kept)} +def check_licenses(components, allow=None, deny=None): + from je_auto_control.utils.license_policy import evaluate_sbom + if isinstance(components, dict): + components = components.get("components", []) + violations = evaluate_sbom(components, allow=allow, deny=deny) + return {"violations": violations, "count": len(violations)} + + def run_saga(steps): from je_auto_control.utils.saga import run_saga as _run result = _run(steps) diff --git a/test/unit_test/headless/test_license_policy_batch.py b/test/unit_test/headless/test_license_policy_batch.py new file mode 100644 index 00000000..e32f0cc9 --- /dev/null +++ b/test/unit_test/headless/test_license_policy_batch.py @@ -0,0 +1,101 @@ +"""Headless tests for the SPDX license-policy gate. Pure stdlib, no Qt.""" +import json + +import je_auto_control as ac +from je_auto_control.utils.license_policy import ( + DEFAULT_COPYLEFT, evaluate_license, evaluate_sbom, + license_findings_to_sarif, normalize_spdx) +from je_auto_control.utils.sarif import to_sarif + +ALLOW = ["MIT", "Apache-2.0", "BSD-3-Clause"] +COMPONENTS = [ + {"name": "a", "version": "1.0", "licenses": [{"license": {"name": "MIT"}}]}, + {"name": "b", "version": "2.0", + "licenses": [{"license": {"name": "GPL-3.0-only"}}]}, + {"name": "c", "version": "3.0"}, + {"name": "d", "version": "4.0", "licenses": [{"expression": "MIT OR Apache-2.0"}]}, +] + + +def test_normalize_spdx_aliases(): + assert normalize_spdx("MIT License") == "MIT" + assert normalize_spdx("Apache 2.0") == "Apache-2.0" + assert normalize_spdx("GPL-3.0-only") == "GPL-3.0-only" + assert normalize_spdx("") == "" + + +def test_evaluate_license_allowlist(): + assert evaluate_license("MIT", allow=ALLOW) == "allowed" + assert evaluate_license("GPL-3.0-only", allow=ALLOW) == "denied" + assert evaluate_license("", allow=ALLOW) == "unknown" + + +def test_evaluate_license_denylist_copyleft(): + assert evaluate_license("GPL-3.0-only", deny=DEFAULT_COPYLEFT) == "denied" + assert evaluate_license("MIT", deny=DEFAULT_COPYLEFT) == "allowed" + + +def test_evaluate_license_no_policy_allows_known(): + assert evaluate_license("Anything-1.0") == "allowed" + + +def test_or_expression_is_a_choice(): + assert evaluate_license("MIT OR GPL-3.0-only", allow=["MIT"]) == "allowed" + + +def test_and_expression_requires_all(): + assert evaluate_license("MIT AND GPL-3.0-only", allow=["MIT"]) == "denied" + + +def test_evaluate_sbom_violations(): + violations = evaluate_sbom(COMPONENTS, allow=ALLOW) + by_name = {v["name"]: v["status"] for v in violations} + assert by_name == {"b": "denied", "c": "unknown"} + assert "a" not in by_name and "d" not in by_name + + +def test_deny_takes_precedence_in_sbom(): + violations = evaluate_sbom(COMPONENTS, deny=["GPL-3.0-only"]) + assert {v["name"] for v in violations} == {"b", "c"} + + +def test_findings_to_sarif_levels(): + violations = evaluate_sbom(COMPONENTS, allow=ALLOW) + document = to_sarif(license_findings_to_sarif(violations)) + levels = {r["ruleId"]: r["level"] for r in document["runs"][0]["results"]} + assert levels["license/b"] == "error" + assert levels["license/c"] == "warning" + + +def test_license_id_field_supported(): + components = [{"name": "z", "version": "1.0", + "licenses": [{"license": {"id": "GPL-3.0-only"}}]}] + assert evaluate_sbom(components, allow=ALLOW)[0]["status"] == "denied" + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + sbom = {"components": COMPONENTS} + rec = ac.execute_action([[ + "AC_check_licenses", + {"components": json.dumps(sbom), "allow": json.dumps(ALLOW)}, + ]]) + payload = next(v for v in rec.values() if isinstance(v, dict)) + assert payload["count"] == 2 + assert {v["name"] for v in payload["violations"]} == {"b", "c"} + + +def test_wiring(): + assert "AC_check_licenses" in ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + assert "ac_check_licenses" in {t.name for t in build_default_tool_registry()} + from je_auto_control.gui.script_builder.command_schema import _build_specs + assert "AC_check_licenses" in {s.command for s in _build_specs()} + + +def test_facade_exports(): + for attr in ("evaluate_license", "evaluate_sbom", "normalize_spdx", + "license_findings_to_sarif", "DEFAULT_COPYLEFT"): + assert hasattr(ac, attr) + assert attr in ac.__all__