From 2f05e56f6daf54b60e699506be95bb4f7f451d67 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 31 Jul 2025 13:45:45 +0200 Subject: [PATCH 1/2] feat: Add script to generate OpenVEX file This change introduces the `generate_openvex.py` script, which converts the `VEX.cyclonedx.xml` file into a compliant OpenVEX JSON document. ### Highlights * Adds a Python script to automate VEX conversion from CycloneDX format to OpenVEX. * Generates a fully populated OpenVEX document based on vulnerability analysis data in `VEX.cyclonedx.xml`. ### Additional Fixes * Corrects a non-unique `serialNumber` (UUID) that was mistakenly copy-pasted from `commons-bcel`. * Removes unintended indentation from the explanation text, ensuring valid Markdown formatting. --- src/conf/security/README.md | 14 +++ src/conf/security/VEX.cyclonedx.xml | 21 ++-- src/conf/security/generate_openvex.py | 170 ++++++++++++++++++++++++++ src/conf/security/openvex.json | 32 +++++ 4 files changed, 229 insertions(+), 8 deletions(-) create mode 100755 src/conf/security/generate_openvex.py create mode 100644 src/conf/security/openvex.json diff --git a/src/conf/security/README.md b/src/conf/security/README.md index 8629f82a7a..e8c492b412 100644 --- a/src/conf/security/README.md +++ b/src/conf/security/README.md @@ -40,6 +40,10 @@ An experimental [VEX](https://cyclonedx.org/capabilities/vex/) document is also 👉 [`https://raw.githubusercontent.com/apache/commons-text/refs/heads/master/src/conf/security/VEX.cyclonedx.xml`](VEX.cyclonedx.xml) +It is also available in [OpenVEX format](https://github.com/openvex/spec) at: + +👉 [`https://raw.githubusercontent.com/apache/commons-text/refs/heads/master/src/conf/security/openvex.json`](openvex.json) + This document provides information about the **exploitability of known vulnerabilities** in the **dependencies** of Apache Commons Text. ### When is a dependency vulnerability exploitable? @@ -59,3 +63,13 @@ Because Apache Commons libraries (including Text) do **not** bundle their depend * The `analysis` field in the VEX file uses **Markdown** formatting. For more information about CycloneDX, SBOMs, or VEX, visit [cyclonedx.org](https://cyclonedx.org/). + +## Contributing + +To add or update a VEX entry: + +* Edit the CycloneDX VEX document: + 1. Increase the `version` attribute in the `` element. + 2. Update the `timestamp` in the `` section. + 3. Make your changes to the vulnerability information. +* Regenerate the `openvex.json` file by running the `generate-openvex.sh` script. \ No newline at end of file diff --git a/src/conf/security/VEX.cyclonedx.xml b/src/conf/security/VEX.cyclonedx.xml index 7ef177f808..85de5662bf 100644 --- a/src/conf/security/VEX.cyclonedx.xml +++ b/src/conf/security/VEX.cyclonedx.xml @@ -19,12 +19,13 @@ To update this document: 1. Increment the `version` attribute in the element. 2. Update the `timestamp` in the section. + 3. Regenerate the `openvex.json` file using the `generate-openvex.sh` script. --> + serialNumber="urn:uuid:9d64577b-0376-4ee7-b154-5ec26a1803f4" + version="2"> 2025-07-29T12:26:42Z @@ -51,6 +52,10 @@ CVE-2025-48924 + + NVD + https://nvd.nist.gov/vuln/detail/CVE-2025-48924 + GHSA-j288-q9x7-2f5v @@ -65,14 +70,14 @@ update - CVE-2025-48924 is exploitable in Apache Commons Text versions 1.5 and later, but only when all the following conditions are met: +CVE-2025-48924 is exploitable in Apache Commons Text versions 1.5 and later, but only when all the following conditions are met: - * The consuming project includes a vulnerable version of Commons Text on the classpath. - As of version `1.14.1`, Commons Text no longer references a vulnerable version of the `commons-lang3` library in its POM file. - * Unvalidated or unsanitized user input is passed to the `StringSubstitutor` or `StringLookup` classes. - * An interpolator lookup created via `StringLookupFactory.interpolatorLookup()` is used. +* The consuming project includes a vulnerable version of Commons Text on the classpath. + As of version `1.14.1`, Commons Text no longer references a vulnerable version of the `commons-lang3` library in its POM file. +* Unvalidated or unsanitized user input is passed to the `StringSubstitutor` or `StringLookup` classes. +* An interpolator lookup created via `StringLookupFactory.interpolatorLookup()` is used. - If these conditions are satisfied, an attacker may cause an infinite loop by submitting a specially crafted input such as `${const:...}`. +If these conditions are satisfied, an attacker may cause an infinite loop by submitting a specially crafted input such as `${const:...}`. 2025-07-29T12:26:42Z 2025-07-29T12:26:42Z diff --git a/src/conf/security/generate_openvex.py b/src/conf/security/generate_openvex.py new file mode 100755 index 0000000000..b77e0dc8ff --- /dev/null +++ b/src/conf/security/generate_openvex.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +import xml.etree.ElementTree as ET +import json +from datetime import datetime, timezone + +NAMESPACES = { + 'b': 'http://cyclonedx.org/schema/bom/1.6' +} + + +def _find_element(parent: ET.Element, tag: str) -> ET.Element | None: + return parent.find(tag, NAMESPACES) + + +def _find_stripped_text(parent: ET.Element, tag: str) -> str | None: + el = _find_element(parent, tag) + return el.text.strip() if el is not None else None + + +def _add_optional_date(parent: ET.Element, tag: str, target: dict, key: str) -> None: + el = _find_element(parent, tag) + if el is not None and el.text: + try: + dt = datetime.fromisoformat(el.text.strip()).astimezone(timezone.utc) + target[key] = dt.isoformat().replace('+00:00', 'Z') + except ValueError as e: + raise ValueError(f"Invalid ISO date format in <{tag}>: {el.text}") from e + + +def load_cyclonedx(path: str = 'VEX.cyclonedx.xml') -> ET.Element: + return ET.parse(path).getroot() + + +def to_openvex(root: ET.Element) -> dict: + serial_number = root.get('serialNumber') + if not serial_number: + raise ValueError("CycloneDX BOM must have a 'serialNumber' attribute") + + version = int(root.get('version', '1')) + + result = { + '@context': 'https://openvex.dev/ns/v0.2.0', + '@id': f"https://commons.apache.org/security/vex/{serial_number}", + 'author': 'Apache Commons Security Team ', + 'role': 'Security Team', + 'version': version, + 'tooling': ( + "This document was automatically converted from the `VEX.cyclonedx.xml` file.\n" + "Do not edit this file directly, run `generate_openvex.py` to regenerate it." + ) + } + + _add_optional_date(root, 'b:metadata/b:timestamp', result, 'timestamp') + + component = _find_element(root, 'b:metadata/b:component') + if component is None: + raise ValueError("Missing in ") + + product = to_openvex_product(component) + + result['statements'] = [ + to_openvex_statement(vuln, product) + for vuln in root.findall('.//b:vulnerability', NAMESPACES) + ] + + return result + + +def to_openvex_product(component: ET.Element) -> dict: + purl = _find_element(component, 'b:purl') + if purl is None or not purl.text: + raise ValueError("Component must include a non-empty element") + + return { + '@id': purl.text, + 'identifiers': { + 'purl': purl.text + } + } + + +def to_openvex_vulnerability(vuln: ET.Element) -> dict: + cdx_id = _find_stripped_text(vuln, 'b:id') + if not cdx_id: + raise ValueError("Vulnerability must have an ") + + entry = {'name': cdx_id} + + source = _find_element(vuln, 'b:source') + if source is not None: + entry['@id'] = _find_stripped_text(source, 'b:url') + + entry['aliases'] = [ + _find_stripped_text(ref, 'b:id') + for ref in vuln.findall('b:references/b:reference', NAMESPACES) + ] + + return entry + + +def to_openvex_statement(vuln: ET.Element, product: dict) -> dict: + analysis = _find_element(vuln, 'b:analysis') + if analysis is None: + raise ValueError("Missing in vulnerability") + + state = _find_stripped_text(analysis, 'b:state') + if not state: + raise ValueError("Missing in vulnerability analysis") + + statement = { + 'products': [product], + 'vulnerability': to_openvex_vulnerability(vuln), + 'status': to_openvex_status(state) + } + + justification = _find_stripped_text(analysis, 'b:justification') + if justification: + statement['justification'] = to_openvex_justification(justification) + + detail = _find_stripped_text(analysis, 'b:detail') + if detail: + statement['status_notes'] = detail + + _add_optional_date(analysis, 'b:firstIssued', statement, 'timestamp') + _add_optional_date(analysis, 'b:lastUpdated', statement, 'last_updated') + + return statement + + +def to_openvex_status(cdx_status: str) -> str: + mapping = { + "resolved": "fixed", + "exploitable": "affected", + "in_triage": "under_investigation", + "false_positive": "not_affected", + "not_affected": "not_affected" + } + status = mapping.get(cdx_status.strip().lower()) + if not status: + raise ValueError(f"Unknown CycloneDX status: '{cdx_status}'") + return status + + +def to_openvex_justification(cdx_justification: str) -> str: + mapping = { + "code_not_present": "vulnerable_code_not_present", + "code_not_reachable": "vulnerable_code_not_in_execute_path", + "requires_configuration": "vulnerable_code_cannot_be_controlled_by_adversary", + "requires_dependency": "component_not_present", + "requires_environment": "vulnerable_code_cannot_be_controlled_by_adversary", + "protected_by_compiler": "inline_mitigations_already_exist", + "protected_at_runtime": "inline_mitigations_already_exist", + "protected_by_mitigating_control": "inline_mitigations_already_exist" + } + result = mapping.get(cdx_justification.strip().lower()) + if not result: + raise ValueError(f"Unknown CycloneDX justification: '{cdx_justification}'") + return result + + +def main(): + cyclonedx_root = load_cyclonedx() + openvex_doc = to_openvex(cyclonedx_root) + with open('openvex.json', 'w') as f: + json.dump(openvex_doc, f, indent=2) + print("OpenVEX document written to 'openvex.json'") + + +if __name__ == "__main__": + main() diff --git a/src/conf/security/openvex.json b/src/conf/security/openvex.json new file mode 100644 index 0000000000..a287ca5081 --- /dev/null +++ b/src/conf/security/openvex.json @@ -0,0 +1,32 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://commons.apache.org/security/vex/urn:uuid:9d64577b-0376-4ee7-b154-5ec26a1803f4", + "author": "Apache Commons Security Team ", + "role": "Security Team", + "version": 2, + "tooling": "This document was automatically converted from the `VEX.cyclonedx.xml` file.\nDo not edit this file directly, run `generate_openvex.py` to regenerate it.", + "timestamp": "2025-07-29T12:26:42Z", + "statements": [ + { + "products": [ + { + "@id": "pkg:maven/org.apache.commons/commons-text?type=jar", + "identifiers": { + "purl": "pkg:maven/org.apache.commons/commons-text?type=jar" + } + } + ], + "vulnerability": { + "name": "CVE-2025-48924", + "@id": "https://nvd.nist.gov/vuln/detail/CVE-2025-48924", + "aliases": [ + "GHSA-j288-q9x7-2f5v" + ] + }, + "status": "affected", + "status_notes": "CVE-2025-48924 is exploitable in Apache Commons Text versions 1.5 and later, but only when all the following conditions are met:\n\n* The consuming project includes a vulnerable version of Commons Text on the classpath.\n As of version `1.14.1`, Commons Text no longer references a vulnerable version of the `commons-lang3` library in its POM file.\n* Unvalidated or unsanitized user input is passed to the `StringSubstitutor` or `StringLookup` classes.\n* An interpolator lookup created via `StringLookupFactory.interpolatorLookup()` is used.\n\nIf these conditions are satisfied, an attacker may cause an infinite loop by submitting a specially crafted input such as `${const:...}`.", + "timestamp": "2025-07-29T12:26:42Z", + "last_updated": "2025-07-29T12:26:42Z" + } + ] +} \ No newline at end of file From 0ddf5e12211d9df42c99da4131606d4bdb8e793a Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 31 Jul 2025 14:12:28 +0200 Subject: [PATCH 2/2] fix: Add required `action_statement` field --- src/conf/security/VEX.cyclonedx.xml | 4 ++++ src/conf/security/generate_openvex.py | 7 +++++++ src/conf/security/openvex.json | 1 + 3 files changed, 12 insertions(+) diff --git a/src/conf/security/VEX.cyclonedx.xml b/src/conf/security/VEX.cyclonedx.xml index 85de5662bf..2fd4d7e48f 100644 --- a/src/conf/security/VEX.cyclonedx.xml +++ b/src/conf/security/VEX.cyclonedx.xml @@ -64,6 +64,10 @@ + +Check if untrusted user input is passed to the `StringSubstitutor` or `StringLookup` classes, +and if so, upgrade to Apache Commons Lang 3.18.0 or later. + exploitable diff --git a/src/conf/security/generate_openvex.py b/src/conf/security/generate_openvex.py index b77e0dc8ff..8dd62a595a 100755 --- a/src/conf/security/generate_openvex.py +++ b/src/conf/security/generate_openvex.py @@ -121,6 +121,13 @@ def to_openvex_statement(vuln: ET.Element, product: dict) -> dict: if detail: statement['status_notes'] = detail + remediation = _find_stripped_text(vuln, 'b:recommendation') + if remediation: + statement['action_statement'] = remediation + else: + if statement['status'] == 'affected': + raise ValueError("Affected vulnerabilities must have a element") + _add_optional_date(analysis, 'b:firstIssued', statement, 'timestamp') _add_optional_date(analysis, 'b:lastUpdated', statement, 'last_updated') diff --git a/src/conf/security/openvex.json b/src/conf/security/openvex.json index a287ca5081..175568b742 100644 --- a/src/conf/security/openvex.json +++ b/src/conf/security/openvex.json @@ -25,6 +25,7 @@ }, "status": "affected", "status_notes": "CVE-2025-48924 is exploitable in Apache Commons Text versions 1.5 and later, but only when all the following conditions are met:\n\n* The consuming project includes a vulnerable version of Commons Text on the classpath.\n As of version `1.14.1`, Commons Text no longer references a vulnerable version of the `commons-lang3` library in its POM file.\n* Unvalidated or unsanitized user input is passed to the `StringSubstitutor` or `StringLookup` classes.\n* An interpolator lookup created via `StringLookupFactory.interpolatorLookup()` is used.\n\nIf these conditions are satisfied, an attacker may cause an infinite loop by submitting a specially crafted input such as `${const:...}`.", + "action_statement": "Check if untrusted user input is passed to the `StringSubstitutor` or `StringLookup` classes,\nand if so, upgrade to Apache Commons Lang 3.18.0 or later.", "timestamp": "2025-07-29T12:26:42Z", "last_updated": "2025-07-29T12:26:42Z" }