diff --git a/README.md b/README.md index 5e750972..4d410e11 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [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) - [What's new (2026-06-20) — SARIF 2.1.0 Findings Export](#whats-new-2026-06-20--sarif-210-findings-export) @@ -111,6 +112,12 @@ --- +## 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). + +- **`vex_statement` / `build_vex` / `apply_vex`** (`AC_apply_vex`, `ac_apply_vex`): the OSV scanner surfaces every known CVE forever — there was no way to record "we checked, this one doesn't affect us". This authors [OpenVEX](https://openvex.dev) 0.2.0 statements and applies them to the scanner's findings: `not_affected`/`fixed` **suppress** a finding, `affected`/`under_investigation` **annotate** it. Statements join on the vuln id *or* an alias, optionally product-scoped; `not_affected` requires a justification or impact statement. Pure-stdlib; chains directly after `AC_scan_vulns`. + ## What's new (2026-06-21) — Dependency Vulnerability Scanning (OSV) Match the SBOM against known CVEs. Full reference: [`docs/source/Eng/doc/new_features/v58_features_doc.rst`](docs/source/Eng/doc/new_features/v58_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index d5da819a..e05834c1 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (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-验证) - [本次更新 (2026-06-20) — SARIF 2.1.0 发现项目导出](#本次更新-2026-06-20--sarif-210-发现项目导出) @@ -110,6 +111,12 @@ --- +## 本次更新 (2026-06-21) — OpenVEX 漏洞分级 + +抑制不影响你的漏洞。完整参考:[`docs/source/Zh/doc/new_features/v59_features_doc.rst`](../docs/source/Zh/doc/new_features/v59_features_doc.rst)。 + +- **`vex_statement` / `build_vex` / `apply_vex`**(`AC_apply_vex`、`ac_apply_vex`):OSV 扫描器会让每个已知 CVE 一直出现 —— 没有办法记录「我们查过了,这个不影响我们」。本功能撰写 [OpenVEX](https://openvex.dev) 0.2.0 陈述并套用到扫描器的发现项目:`not_affected`/`fixed` **抑制**一项发现,`affected`/`under_investigation` **标注**它。陈述以漏洞 id *或*别名配对,并可限定产品;`not_affected` 需附理由或冲击说明。纯标准库;可直接接在 `AC_scan_vulns` 之后。 + ## 本次更新 (2026-06-21) — 依赖项漏洞扫描(OSV) 以 SBOM 比对已知 CVE。完整参考:[`docs/source/Zh/doc/new_features/v58_features_doc.rst`](../docs/source/Zh/doc/new_features/v58_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index dd443bf5..cc5ea0bf 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (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-驗證) - [本次更新 (2026-06-20) — SARIF 2.1.0 發現項目匯出](#本次更新-2026-06-20--sarif-210-發現項目匯出) @@ -110,6 +111,12 @@ --- +## 本次更新 (2026-06-21) — OpenVEX 漏洞分級 + +抑制不影響你的漏洞。完整參考:[`docs/source/Zh/doc/new_features/v59_features_doc.rst`](../docs/source/Zh/doc/new_features/v59_features_doc.rst)。 + +- **`vex_statement` / `build_vex` / `apply_vex`**(`AC_apply_vex`、`ac_apply_vex`):OSV 掃描器會讓每個已知 CVE 一直出現 —— 沒有辦法記錄「我們查過了,這個不影響我們」。本功能撰寫 [OpenVEX](https://openvex.dev) 0.2.0 陳述並套用到掃描器的發現項目:`not_affected`/`fixed` **抑制**一項發現,`affected`/`under_investigation` **標註**它。陳述以漏洞 id *或*別名配對,並可限定產品;`not_affected` 需附理由或衝擊說明。純標準函式庫;可直接接在 `AC_scan_vulns` 之後。 + ## 本次更新 (2026-06-21) — 相依套件漏洞掃描(OSV) 以 SBOM 比對已知 CVE。完整參考:[`docs/source/Zh/doc/new_features/v58_features_doc.rst`](../docs/source/Zh/doc/new_features/v58_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v59_features_doc.rst b/docs/source/Eng/doc/new_features/v59_features_doc.rst new file mode 100644 index 00000000..58e080fa --- /dev/null +++ b/docs/source/Eng/doc/new_features/v59_features_doc.rst @@ -0,0 +1,52 @@ +OpenVEX Vulnerability Triage +============================ + +``scan_components`` (the OSV matcher) produces vulnerability findings, but every +known CVE then shows up on every run forever — there was no way to record "we +looked, this one does not affect us" and drop it. VEX (Vulnerability +Exploitability eXchange) is the standard for exactly that triage signal. This +authors `OpenVEX `_ 0.2.0 statements and applies them to +the scanner's findings. + +``not_affected`` / ``fixed`` statements **suppress** a finding; ``affected`` / +``under_investigation`` **annotate** it with the assessed status. Statements +join to findings on the vulnerability id *or* any of its aliases, optionally +scoped to a product. Pure standard library (``hashlib`` + ``json`` + +``datetime``); imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + scan_components, vex_statement, build_vex, apply_vex) + + findings = scan_components(sbom["components"], advisories) + + statements = [ + vex_statement("CVE-2024-1", "not_affected", + products=["pkg:pypi/foo"], + justification="vulnerable_code_not_present"), + vex_statement("GHSA-bar", "under_investigation"), + ] + vex = build_vex(statements, author="security@example.com") + + triaged = apply_vex(findings, vex) + # CVE-2024-1 (not_affected) dropped; GHSA-bar kept with vex_status set + +``vex_statement`` validates the inputs: ``status`` must be one of +``VEX_STATUSES`` (``not_affected`` / ``affected`` / ``fixed`` / +``under_investigation``); a ``not_affected`` statement must carry a +``justification`` (one of ``VEX_JUSTIFICATIONS``) or an ``impact_statement``. +``build_vex`` wraps statements in an OpenVEX document (pass an explicit +``timestamp`` for a reproducible ``@id``). ``apply_vex`` returns the surviving +findings, each non-suppressed match annotated with ``vex_status``. + +Executor command +---------------- + +``AC_apply_vex`` takes ``findings`` and a ``vex`` document (each a list/object +or a JSON string) and returns ``{findings, count}`` of the survivors. The same +operation is exposed as the MCP tool ``ac_apply_vex`` and as a Script Builder +command under **Security**. It chains directly after ``AC_scan_vulns``. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index ae96698f..d9727e50 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -81,6 +81,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v56_features_doc doc/new_features/v57_features_doc doc/new_features/v58_features_doc + doc/new_features/v59_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/v59_features_doc.rst b/docs/source/Zh/doc/new_features/v59_features_doc.rst new file mode 100644 index 00000000..ae29eb8f --- /dev/null +++ b/docs/source/Zh/doc/new_features/v59_features_doc.rst @@ -0,0 +1,45 @@ +OpenVEX 漏洞分級 +=============== + +``scan_components``(OSV 比對器)會產生漏洞發現項目,但之後每次執行每個已知 CVE 都會一直出現 +—— 沒有辦法記錄「我們看過了,這個不影響我們」並把它捨棄。VEX(Vulnerability Exploitability +eXchange)正是這個分級訊號的標準。本功能撰寫 `OpenVEX `_ 0.2.0 陳述並 +套用到掃描器的發現項目上。 + +``not_affected`` / ``fixed`` 陳述會**抑制**一項發現;``affected`` / ``under_investigation`` +則以評估後的狀態**標註**它。陳述以漏洞 id *或*其任一別名與發現項目配對,並可選擇性地限定到某個 +產品。純標準函式庫(``hashlib`` + ``json`` + ``datetime``);不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + scan_components, vex_statement, build_vex, apply_vex) + + findings = scan_components(sbom["components"], advisories) + + statements = [ + vex_statement("CVE-2024-1", "not_affected", + products=["pkg:pypi/foo"], + justification="vulnerable_code_not_present"), + vex_statement("GHSA-bar", "under_investigation"), + ] + vex = build_vex(statements, author="security@example.com") + + triaged = apply_vex(findings, vex) + # CVE-2024-1(not_affected)被捨棄;GHSA-bar 保留並設定 vex_status + +``vex_statement`` 會驗證輸入:``status`` 必須是 ``VEX_STATUSES`` 之一(``not_affected`` / +``affected`` / ``fixed`` / ``under_investigation``);``not_affected`` 陳述必須帶有 +``justification``(``VEX_JUSTIFICATIONS`` 其一)或 ``impact_statement``。``build_vex`` 把 +陳述包成 OpenVEX 文件(傳入明確的 ``timestamp`` 可得到可重現的 ``@id``)。``apply_vex`` 回傳 +存活的發現項目,每個未被抑制的配對都會標註 ``vex_status``。 + +執行器命令 +---------- + +``AC_apply_vex`` 接受 ``findings`` 與一份 ``vex`` 文件(各為 list/object 或 JSON 字串),回傳 +存活者的 ``{findings, count}``。同一操作亦以 MCP 工具 ``ac_apply_vex`` 以及 Script Builder +中 **Security** 分類下的命令提供。它可直接接在 ``AC_scan_vulns`` 之後。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 3172b515..62c3eb5a 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -81,6 +81,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v56_features_doc doc/new_features/v57_features_doc doc/new_features/v58_features_doc + doc/new_features/v59_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 b1474cb4..ee863902 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -306,6 +306,10 @@ from je_auto_control.utils.vuln_scan import ( findings_to_sarif, is_affected, match_package, scan_components, version_key, ) +# OpenVEX triage over vulnerability findings +from je_auto_control.utils.vex import ( + VEX_JUSTIFICATIONS, VEX_STATUSES, apply_vex, build_vex, vex_statement, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -778,6 +782,8 @@ def start_autocontrol_gui(*args, **kwargs): "SchemaValidationResult", "assert_schema", "is_valid", "validate_json", "findings_to_sarif", "is_affected", "match_package", "scan_components", "version_key", + "VEX_JUSTIFICATIONS", "VEX_STATUSES", "apply_vex", "build_vex", + "vex_statement", # 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 be5c4a55..77b3a829 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1168,6 +1168,17 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Match SBOM components against an OSV advisory database.", )) + specs.append(CommandSpec( + "AC_apply_vex", "Security", "Apply VEX Triage to Findings", + fields=( + FieldSpec("findings", FieldType.STRING, + placeholder='[{"id": "GHSA-...", "package": "foo"}]'), + FieldSpec("vex", FieldType.STRING, + placeholder='{"statements": [{"vulnerability": ' + '{"name": "CVE-..."}, "status": "not_affected"}]}'), + ), + description="Suppress not_affected/fixed vulns via an OpenVEX document.", + )) 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 fd9e0c47..1ff26c26 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2885,6 +2885,18 @@ def _scan_vulns(components: Any, advisories: Any = None, return result +def _apply_vex(findings: Any, vex: Any) -> Dict[str, Any]: + """Adapter: suppress VEX'd vulnerability findings (each JSON string/obj).""" + import json + from je_auto_control.utils.vex import apply_vex + if isinstance(findings, str): + findings = json.loads(findings) + if isinstance(vex, str): + vex = json.loads(vex) + kept = apply_vex(findings, vex) + return {"findings": kept, "count": len(kept)} + + 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.""" @@ -3665,6 +3677,7 @@ def __init__(self): "AC_heal_stats": _heal_stats, "AC_scan_secrets": _scan_secrets, "AC_scan_vulns": _scan_vulns, + "AC_apply_vex": _apply_vex, "AC_generate_sop": _generate_sop, "AC_tween_drag": _tween_drag, "AC_list_plugins": _list_plugins, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 0a5e717a..17a184af 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3262,6 +3262,23 @@ def vuln_scan_tools() -> List[MCPTool]: ] +def vex_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_apply_vex", + description=("Apply an OpenVEX document to vulnerability " + "'findings': drop the ones marked not_affected/fixed " + "and annotate the rest with their VEX status. Returns " + "{findings, count}."), + input_schema=schema( + {"findings": {"type": "array"}, "vex": {"type": "object"}}, + ["findings", "vex"]), + handler=h.apply_vex, + annotations=READ_ONLY, + ), + ] + + def saga_tools() -> List[MCPTool]: return [ MCPTool( @@ -4456,7 +4473,7 @@ 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, saga_tools, + jsonpath_tools, json_schema_tools, vuln_scan_tools, vex_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 8281f8eb..aa24e88b 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1569,6 +1569,12 @@ def scan_vulns(components, advisories=None): return {"findings": findings, "count": len(findings)} +def apply_vex(findings, vex): + from je_auto_control.utils.vex import apply_vex as _apply + kept = _apply(findings, vex) + return {"findings": kept, "count": len(kept)} + + def run_saga(steps): from je_auto_control.utils.saga import run_saga as _run result = _run(steps) diff --git a/je_auto_control/utils/vex/__init__.py b/je_auto_control/utils/vex/__init__.py new file mode 100644 index 00000000..a0c854e2 --- /dev/null +++ b/je_auto_control/utils/vex/__init__.py @@ -0,0 +1,9 @@ +"""OpenVEX statement authoring and triage over vulnerability findings.""" +from je_auto_control.utils.vex.vex import ( + VEX_JUSTIFICATIONS, VEX_STATUSES, apply_vex, build_vex, vex_statement, +) + +__all__ = [ + "VEX_JUSTIFICATIONS", "VEX_STATUSES", "apply_vex", "build_vex", + "vex_statement", +] diff --git a/je_auto_control/utils/vex/vex.py b/je_auto_control/utils/vex/vex.py new file mode 100644 index 00000000..d82e476a --- /dev/null +++ b/je_auto_control/utils/vex/vex.py @@ -0,0 +1,121 @@ +"""Author and apply OpenVEX statements over vulnerability findings. + +``utils/vuln_scan`` produces vulnerability findings, but every known CVE then +shows up forever — there was no way to record "we looked, this one does not +affect us" and drop it. VEX (Vulnerability Exploitability eXchange) is the +standard for exactly that triage signal. This builds `OpenVEX +`_ 0.2.0 statements and applies them to the findings from +the scanner: ``not_affected`` / ``fixed`` statements suppress a finding, while +``affected`` / ``under_investigation`` annotate it with the assessed status. + +Pure standard library (``hashlib`` + ``json`` + ``datetime``); imports no +``PySide6``. +""" +import datetime +import hashlib +import json +from typing import Any, Dict, List, Mapping, Optional, Sequence + +from je_auto_control.utils.exception.exceptions import AutoControlException + +_CONTEXT = "https://openvex.dev/ns/v0.2.0" + +VEX_STATUSES = frozenset({ + "not_affected", "affected", "fixed", "under_investigation", +}) +VEX_JUSTIFICATIONS = frozenset({ + "component_not_present", "vulnerable_code_not_present", + "vulnerable_code_not_in_execute_path", + "vulnerable_code_cannot_be_controlled_by_adversary", + "inline_mitigations_already_exist", +}) +_SUPPRESSED = frozenset({"not_affected", "fixed"}) + + +def _check_statement(status: str, justification: Optional[str], + impact_statement: Optional[str]) -> None: + if status not in VEX_STATUSES: + raise AutoControlException(f"invalid VEX status {status!r}") + if status == "not_affected" and not (justification or impact_statement): + raise AutoControlException( + "not_affected requires a justification or impact_statement") + if justification and justification not in VEX_JUSTIFICATIONS: + raise AutoControlException(f"invalid VEX justification {justification!r}") + + +def vex_statement(vuln_id: str, status: str, *, + products: Optional[Sequence[str]] = None, + justification: Optional[str] = None, + impact_statement: Optional[str] = None) -> Dict[str, Any]: + """Build one validated OpenVEX statement for ``vuln_id``.""" + _check_statement(status, justification, impact_statement) + statement: Dict[str, Any] = { + "vulnerability": {"name": str(vuln_id)}, + "products": [{"@id": str(product)} for product in (products or [])], + "status": status, + } + if justification: + statement["justification"] = justification + if impact_statement: + statement["impact_statement"] = impact_statement + return statement + + +def _doc_id(statements: Sequence[Mapping[str, Any]]) -> str: + basis = json.dumps(list(statements), sort_keys=True, default=str) + digest = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:12] + return f"https://openvex.dev/docs/auto-{digest}" + + +def build_vex(statements: Sequence[Mapping[str, Any]], *, + author: str = "AutoControl", vex_id: Optional[str] = None, + version: int = 1, timestamp: Optional[str] = None) -> Dict[str, Any]: + """Wrap ``statements`` in an OpenVEX 0.2.0 document.""" + when = timestamp or datetime.datetime.now( + datetime.timezone.utc).isoformat() + return { + "@context": _CONTEXT, + "@id": vex_id or _doc_id(statements), + "author": str(author), + "timestamp": when, + "version": int(version), + "statements": list(statements), + } + + +def _statement_matches(statement: Mapping[str, Any], + finding: Mapping[str, Any]) -> bool: + name = statement.get("vulnerability", {}).get("name", "") + identifiers = {finding.get("id")} | set(finding.get("aliases", [])) + if name not in identifiers: + return False + products = [str(item.get("@id", "")) for item in + statement.get("products", [])] + if not products: + return True + package = str(finding.get("package", "")) + return bool(package) and any(package in product for product in products) + + +def _resolve_status(finding: Mapping[str, Any], + statements: Sequence[Mapping[str, Any]]) -> Optional[str]: + for statement in statements: + if _statement_matches(statement, finding): + return statement.get("status") + return None + + +def apply_vex(findings: Sequence[Mapping[str, Any]], + vex_doc: Mapping[str, Any]) -> List[Dict[str, Any]]: + """Return findings with ``not_affected``/``fixed`` ones suppressed.""" + statements = vex_doc.get("statements", []) + kept: List[Dict[str, Any]] = [] + for finding in findings: + status = _resolve_status(finding, statements) + if status in _SUPPRESSED: + continue + entry = dict(finding) + if status is not None: + entry["vex_status"] = status + kept.append(entry) + return kept diff --git a/test/unit_test/headless/test_vex_batch.py b/test/unit_test/headless/test_vex_batch.py new file mode 100644 index 00000000..433303a6 --- /dev/null +++ b/test/unit_test/headless/test_vex_batch.py @@ -0,0 +1,132 @@ +"""Headless tests for OpenVEX authoring and triage. Pure stdlib, no Qt.""" +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.vex import ( + VEX_JUSTIFICATIONS, VEX_STATUSES, apply_vex, build_vex, vex_statement) +from je_auto_control.utils.vuln_scan import scan_components + +FINDINGS = [ + {"id": "GHSA-foo", "package": "foo", "version": "1.0.0", + "aliases": ["CVE-2024-1"], "severity": "error"}, + {"id": "GHSA-bar", "package": "bar", "version": "1.0.0", + "aliases": [], "severity": "note"}, +] + + +def test_statuses_and_justifications_constants(): + assert "not_affected" in VEX_STATUSES + assert "vulnerable_code_not_present" in VEX_JUSTIFICATIONS + + +def test_vex_statement_shape(): + statement = vex_statement("CVE-2024-1", "not_affected", + products=["pkg:pypi/foo"], + justification="vulnerable_code_not_present") + assert statement["vulnerability"]["name"] == "CVE-2024-1" + assert statement["status"] == "not_affected" + assert statement["products"] == [{"@id": "pkg:pypi/foo"}] + assert statement["justification"] == "vulnerable_code_not_present" + + +def test_vex_statement_validation(): + with pytest.raises(AutoControlException): + vex_statement("X", "bogus") + with pytest.raises(AutoControlException): + vex_statement("X", "not_affected") + with pytest.raises(AutoControlException): + vex_statement("X", "affected", justification="not-a-real-one") + + +def test_build_vex_envelope_is_deterministic_with_timestamp(): + statement = vex_statement("CVE-2024-1", "fixed") + doc = build_vex([statement], author="sec@me", + timestamp="2026-06-21T00:00:00+00:00") + again = build_vex([statement], author="sec@me", + timestamp="2026-06-21T00:00:00+00:00") + assert doc["@context"] == "https://openvex.dev/ns/v0.2.0" + assert doc["author"] == "sec@me" + assert doc["@id"] == again["@id"] + + +def test_apply_vex_suppresses_via_alias_match(): + statement = vex_statement("CVE-2024-1", "not_affected", + products=["pkg:pypi/foo"], + justification="vulnerable_code_not_present") + doc = build_vex([statement], timestamp="2026-06-21T00:00:00+00:00") + kept = apply_vex(FINDINGS, doc) + assert [f["id"] for f in kept] == ["GHSA-bar"] + + +def test_apply_vex_annotates_non_suppressed(): + statement = vex_statement("GHSA-bar", "under_investigation") + doc = build_vex([statement], timestamp="2026-06-21T00:00:00+00:00") + kept = apply_vex(FINDINGS, doc) + bar = next(f for f in kept if f["id"] == "GHSA-bar") + assert bar["vex_status"] == "under_investigation" + + +def test_apply_vex_product_scoping(): + # statement scoped to a different product must not suppress foo + statement = vex_statement("CVE-2024-1", "not_affected", + products=["pkg:pypi/other"], + justification="component_not_present") + doc = build_vex([statement], timestamp="2026-06-21T00:00:00+00:00") + assert [f["id"] for f in apply_vex(FINDINGS, doc)] == ["GHSA-foo", "GHSA-bar"] + + +def test_apply_vex_no_statements_keeps_all(): + doc = build_vex([], timestamp="2026-06-21T00:00:00+00:00") + assert len(apply_vex(FINDINGS, doc)) == len(FINDINGS) + + +def test_composes_with_vuln_scanner(): + advisories = [{ + "id": "GHSA-foo", "aliases": ["CVE-2024-1"], + "database_specific": {"severity": "HIGH"}, + "affected": [{"package": {"ecosystem": "PyPI", "name": "foo"}, + "ranges": [{"type": "ECOSYSTEM", + "events": [{"introduced": "0"}, + {"fixed": "2.0.0"}]}]}], + }] + components = [{"name": "foo", "version": "1.0.0", + "purl": "pkg:pypi/foo@1.0.0"}] + findings = scan_components(components, advisories) + assert [f["id"] for f in findings] == ["GHSA-foo"] + statement = vex_statement("CVE-2024-1", "not_affected", + justification="vulnerable_code_not_present") + doc = build_vex([statement], timestamp="2026-06-21T00:00:00+00:00") + assert apply_vex(findings, doc) == [] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + statement = vex_statement("CVE-2024-1", "not_affected", + justification="vulnerable_code_not_present") + doc = build_vex([statement], timestamp="2026-06-21T00:00:00+00:00") + rec = ac.execute_action([[ + "AC_apply_vex", + {"findings": json.dumps(FINDINGS), "vex": json.dumps(doc)}, + ]]) + payload = next(v for v in rec.values() if isinstance(v, dict)) + assert payload["count"] == 1 + assert payload["findings"][0]["id"] == "GHSA-bar" + + +def test_wiring(): + assert "AC_apply_vex" in ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + assert "ac_apply_vex" in {t.name for t in build_default_tool_registry()} + from je_auto_control.gui.script_builder.command_schema import _build_specs + assert "AC_apply_vex" in {s.command for s in _build_specs()} + + +def test_facade_exports(): + for attr in ("apply_vex", "build_vex", "vex_statement", "VEX_STATUSES", + "VEX_JUSTIFICATIONS"): + assert hasattr(ac, attr) + assert attr in ac.__all__