{"id":"PYSEC-2026-4030","summary":"Trestle is vulnerable to arbitrary file write via path traversal in author generate commands (Incomplete fix of CVE-2026-46345)","details":"**At a glance**\n\n- **Actor:** attacker who controls the -o/--output argument to trestle author {catalog,profile,ssp}-generate (e.g. via a CI pipeline that derives the output directory from repository-controlled data)\n\n- **Primitive:** attacker-controlled --output value reaches trestle_root / args.output write sink with only is_directory_name_allowed() (parts[0]-only task-name-collision check), not the PathSecurityValidator.validate_local_path() guard added by the CVE-2026-46345 fix\n\n- **Impact:** arbitrary-location file write outside the trestle workspace as the process owner (8.4 High; conservative C:N variant 7.7, still High); with --force-overwrite, the attacker-chosen directory is first recursively deleted shutil.rmtree)\n\n- **Precondition:** attacker influences the -o argument in a CI/automation pipeline or multi-tenant trestle workspace running these generate subcommands\n\n- **Fix:** call PathSecurityValidator.validate_local_path(markdown_path, trestle_root) immediately after building markdown_path in catalog.py, ssp.py, and prof.py, mirroring the existing jinja fix\n\n## Overview\n\nThe remediation for **CVE-2026-46345 / GHSA-4q5v-7g7x-j79w** (\"Arbitrary File Write via Path Traversal in compliance-trestle – jinja\") added a new PathSecurityValidator.validate_local_path() guard and wired it into the jinja command's output path. The identical output = trestle_root / args.output write pattern in the sibling author commands — catalog-generate, profile-generate, and ssp-generate — was **not** updated. Those commands instead rely on is_directory_name_allowed(), a task-name-collision check that does not stop path traversal: an absolute --output or a --output whose first component is innocuous subdir/../../../...) escapes the trestle workspace and writes generated markdown under an attacker-chosen output root outside the workspace, subject to the invoking process's filesystem permissions.\n\n## Impact\n\n**Threat model.** This is not a claim that a local user harms themselves by intentionally choosing an unsafe -o. The security boundary is crossed when a trusted automation job, CI workflow, shared trestle service, or wrapper invokes one of these subcommands and derives --output from repository-controlled, tenant-controlled, or otherwise untrusted data while expecting trestle to keep generated output inside the workspace. The attacker does not need local shell access to the trestle host; they only need influence over the data that the trusted automation maps into the --output argument.\n\n**Primary claim (confirmed):** Such an invocation writes control-markdown files **outside** the trestle workspace — arbitrary-location file write as the process owner. This is runtime-confirmed for catalog-generate: catalog-generate -o /tmp/TRESTLE_ESCAPE_ABS produced files outside the workspace on a v4.0.3 install while the same install blocked the equivalent jinja -o with a Security violation error. profile-generate and ssp-generate are source-confirmed siblings (identical trestle_root / args.output join, the same is_directory_name_allowed-only gate, no validate_local_path call); see Runtime-confirmed scope in the End-to-end verification below.\n\n**Destructive variant --force-overwrite, source-confirmed):** before generating, the force-overwrite path clears the selected output directory via clear_folder(...) trestle/core/commands/common/cmd_utils.py), which performs shutil.rmtree on that directory. Because clear_folder early-returns unless the target is an existing **directory**, the primitive is recursive deletion of an attacker-selected directory tree outside the workspace (e.g. wiping a directory the process owner can write), not pinpoint deletion of an arbitrary single file. This is the integrity + availability impact behind I:HA:H.\n\n**Secondary (conditional) escalation:** The affected population extends to every consumer that runs these generate subcommands in a CI/automation pipeline, shared/multi-tenant trestle workspace, or wrapper that forwards an externally supplied name — the same threat model GitHub/the maintainer accepted for CVE-2026-46345. Indirect code execution (e.g. overwriting a script the CI pipeline later invokes) is the bounded escalation beyond the demonstrated file-write primitive.\n\n## Technical Details\n\n**Source → Transform → Sink → Missing-guard → Result:** attacker-controlled --output CLI argument → trestle_root / args.output join in catalog.pyssp.pyprof.py → CatalogAPI.write_catalog_as_markdown() writes files under the resolved path → only is_directory_name_allowed() (parts[0]-only task-name check) applied, not PathSecurityValidator.validate_local_path() → files written outside the trestle workspace.\n\n### The fix is scoped to jinja.py only\n\nBoth fix commits 247fcce2…, 7d107b3a…, \"add path traversal protection and prevent SSTI in jinja templating\") touch only trestle/core/commands/author/jinja.py (+ its tests). The new guard:\n\n```python\n# trestle/core/commands/author/jinja.py\noutput_file = trestle_root / r_output_file\nPathSecurityValidator.validate_local_path(output_file, trestle_root)   # :229 (and :278, :297)\n```\n\nvalidate_local_path trestle/core/remote/security.py:326) is the correct guard — it .resolve()s the path and calls relative_to(trestle_root), rejecting both .. traversal and absolute paths.\n\n### The sibling generate commands were not updated\n\ncatalog-generate, profile-generate, and ssp-generate build the output path with the **same** join but never call validate_local_path. The only check is is_directory_name_allowed:\n\n```python\n# trestle/core/commands/author/catalog.py:69 / ssp.py:97 / prof.py:86  (identical in all three)\nif not file_utils.is_directory_name_allowed(args.output):\n    raise TrestleError(f'{args.output} is not an allowed directory name')\n...\nmarkdown_path = trestle_root / args.output           # catalog.py:90 / prof.py:110 (var markdown_path); ssp.py:111 (var md_path) — same join\n```\n\nis_directory_name_allowed trestle/common/file_utils.py:95) was designed to stop task names that collide with OSCAL model directories, not traversal. It inspects only parts[0]:\n\n```python\ndef is_directory_name_allowed(name: str) -\u003e bool:\n    pathed_name = pathlib.Path(name)\n    root_path = pathed_name.parts[0]\n    if root_path in const.MODEL_TYPE_TO_MODEL_DIR.values(): return False  # blocks \"catalogs\", \"profiles\", ...\n    if root_path[0] == '.':                                return False  # blocks leading \".\" (i.e. \"../x\")\n    if pathed_name.suffix != '':                           return False  # blocks names with a file suffix\n    if '__global__' in pathed_name.parts:                  return False\n    return True\n```\n\nTwo payloads defeat it:\n\n1. **Absolute path** — --output /tmp/pwned. parts[0] is / (not an OSCAL dir, not .-prefixed, no suffix) → allowed. trestle_root / '/tmp/pwned' collapses to /tmp/pwned (pathlib discards the left operand on absolute join).\n\n2. **Non-leading ..** — --output subdir/../../../../../../tmp/pwned. parts[0] is subdir (innocuous) → allowed. The .. segments resolve out of the workspace at write time.\n\nThe validated value flows unchanged to the write sink with no further sanitisation grep for resolve()/relative_to/validate_local_path across the catalog write path returns zero hits): ControlContext.generate(..., md_root=markdown_path, ...) stores it as a dataclass field trestle/core/control_context.py:46) and CatalogAPI.write_catalog_as_markdown() calls self._context.md_root.mkdir(exist_ok=True, parents=True) trestle/core/catalog/catalog_api.py:72) then writes \u003ccontrol-id\u003e.md files under it.\n\n### Additional unguarded siblings create, replicate)\n\ntrestle create trestle/core/commands/create.py:95, desired_model_dir = trestle_root / plural_path / args.output) and trestle replicate replicate.py:90) have **no** is_directory_name_allowed check at all and accept the same absolute / .. --output. They write a structured OSCAL model file and refuse to overwrite an existing target .exists() raises), so the primitive is create-only there — lower impact than the generate commands, but the same missing-guard root cause. These are flagged as related defense-in-depth sinks sharing the root cause, not as the primary impact claim of this report.\n\n### Severity note\n\nMetrics mirror the parent CVE-2026-46345 (8.4, AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). I:HA:H are direct and demonstrated: out-of-workspace file creation, plus recursive shutil.rmtree of an attacker-chosen directory under --force-overwrite. C:H is proposed for consistency with the parent advisory's published score for the same out-of-workspace write boundary; however, the directly demonstrated primitive is write/overwrite rather than file read, so a conservative vector with C:N AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H) yields **7.7, still High**. S:U because trestle writes as the invoking user.\n\n### Why OSCAL id-validation does not prevent this\n\nOSCAL model ids control.id, group.id) are NCName-validated constr(regex=…)) and cannot contain /, \\, or a leading .., so the per-control **leaf** filenames grp1/ac-1.md) cannot themselves traverse. That validation does not help here: the **base** output root md_root = trestle_root / args.output is built from the raw, unconstrained -o/--output CLI string and is joined before those safe leaves. An absolute or non-leading.. -o escapes the workspace, and the NCName-safe leaves are written underneath the escaped root — confirmed by the PoC, where the escape occurs at md_root /tmp/TRESTLE_ESCAPE_ABS) with grp1/ac-1.md written beneath it.\n\n## Reproduction\n\nNon-web target (Python CLI). PoC is a command sequence run against a **local** install of the project's current source (HEAD e22e35bd, reports as v4.0.3) — no vendor infrastructure touched.\n\n### Step 1 — Set up a normal trestle workspace with a one-control catalog\n\n```bash\npip install -e .                      # editable install of the affected source (v4.0.3)\nmkdir /tmp/poc_ws && cd /tmp/poc_ws\ntrestle init\nmkdir -p catalogs/mycat\npython3 - \u003c\u003c'PY'\nimport uuid, json\ncat = {\"catalog\":{\"uuid\":str(uuid.uuid4()),\n  \"metadata\":{\"title\":\"PoC Catalog\",\"last-modified\":\"2026-01-01T00:00:00.000+00:00\",\"version\":\"1.0\",\"oscal-version\":\"1.0.4\"},\n  \"groups\":[{\"id\":\"grp1\",\"title\":\"Group One\",\"controls\":[\n     {\"id\":\"ac-1\",\"title\":\"PoC Control\",\"parts\":[{\"id\":\"ac-1_smt\",\"name\":\"statement\",\"prose\":\"PoC statement prose.\"}]}]}]}}\njson.dump(cat, open(\"catalogs/mycat/catalog.json\",\"w\"), indent=2)\nPY\n```\n\n### Step 2 — Trigger the boundary failure (absolute-path escape)\n\n```bash\ntrestle author catalog-generate -n mycat -o /tmp/TRESTLE_ESCAPE_ABS\nls /tmp/TRESTLE_ESCAPE_ABS/grp1/ac-1.md\n```\n\nRecorded output:\n\n```text\n$ ls /tmp/TRESTLE_ESCAPE_ABS/grp1/ac-1.md\n/tmp/TRESTLE_ESCAPE_ABS/grp1/ac-1.md          # written OUTSIDE /tmp/poc_ws\n$ head -3 /tmp/TRESTLE_ESCAPE_ABS/grp1/ac-1.md\n# ac-1 - \\[Group One\\] PoC Control\n## Control Statement\n```\n\n### Step 3 — Same escape via non-leading .. (defeats is_directory_name_allowed)\n\n```bash\ntrestle author catalog-generate -n mycat -o 'subdir/../../../../../../tmp/TRESTLE_ESCAPE_DOTDOT'\nls /tmp/TRESTLE_ESCAPE_DOTDOT/grp1/ac-1.md   # -\u003e exists, outside the workspace\n```\n\n### Step 4 — Differential: the patched jinja -o is blocked on the SAME install\n\n```bash\necho 'hello {{ 1+1 }}' \u003e template.j2\ntrestle author jinja -i template.j2 -o '/tmp/TRESTLE_JINJA_BLOCKED'\n```\n\nRecorded output (fix is active; proves this is an incomplete fix, not an unpatched version):\n\n```text\nERROR: ... Security violation: Path traversal blocked. Attempted to access\n\"/tmp/TRESTLE_JINJA_BLOCKED\" which is outside the trestle workspace \"/tmp/poc_ws\"\n# (no file created)\n```\n\ncatalog-generate escapes while jinja is blocked → the validate_local_path remediation was never applied to the generate commands.\n\n### End-to-end verification (runtime)\n\n- **Lab setup:** editable install pip install -e .) of the affected source at HEAD e22e35bd (reports as v4.0.3, the release that contains the GHSA-4q5v jinja fix). import trestle.core.commands.author.catalog resolves to the in-tree source file, confirming the run exercises HEAD, not a stale wheel.\n\n- **Observed end-to-end effect (not an intermediate return value):** files physically written outside the workspace — /tmp/TRESTLE_ESCAPE_ABS/grp1/ac-1.md and /tmp/TRESTLE_ESCAPE_DOTDOT/grp1/ac-1.md — while /tmp/poc_ws (the trestle root) contained no such directory. Confirmed by findls.\n\n- **Differential control:** the same install rejects the equivalent jinja -o with Security violation: Path traversal blocked … outside the trestle workspace, writing nothing. The guard exists and works in jinja; it is simply absent from the generate commands.\n\n- **Guard bypass, isolated:** importing is_directory_name_allowed semantics and joining via pathlib confirms -o /tmp/pwned (absolute) and -o subdir/../../../tmp/pwned (innocuous leading component) both pass the check and resolve outside the root, while the naive -o ../../tmp/pwned is the only form the check stops.\n\n- **Runtime-confirmed scope (what was executed vs source-confirmed):** the write escape is runtime-confirmed for catalog-generate (Steps 2–4 above). profile-generate and ssp-generate are source-confirmed siblings — same trestle_root / args.output join prof.py:110, ssp.py:111), same is_directory_name_allowed-only gate prof.py:86, ssp.py:97), no validate_local_path — and should be fixed in the same patch. The --force-overwrite recursive-delete primitive clear_folder → shutil.rmtree, with an early return unless the target is an existing directory) is source-confirmed; the PoC above exercises the write escape, not -fo.\n\n## Suggested Fix\n\n**Root-cause fix:** Mirror the jinja fix in the three generate commands (and, for completeness, createreplicate): after constructing the output path, call the existing guard before any mkdir/write.\n\n```python\n# catalog.py / ssp.py / prof.py, immediately after markdown_path = trestle_root / args.output\nfrom trestle.core.remote.security import PathSecurityValidator\nPathSecurityValidator.validate_local_path(markdown_path, trestle_root)\n```\n\nis_directory_name_allowed() should be retained for its original purpose (OSCAL-dir-collision prevention) but must not be relied on for traversal defence.\n\n**Defense-in-depth:** Harden is_directory_name_allowed to reject absolute paths pathed_name.is_absolute()) and any .. component so it provides a secondary layer even if the primary validate_local_path call is accidentally omitted in future.\n\n## References\n\n- Vendor security policy: https://github.com/oscal-compass/compliance-trestle/security/policy\n\n- Submission endpoint: https://github.com/oscal-compass/compliance-trestle/security/advisories/new\n\n- Parent advisory (incompletely fixed): GHSA-4q5v-7g7x-j79w / CVE-2026-46345 — \"Arbitrary File Write via Path Traversal in compliance-trestle – jinja\"\n\n- Coordinated disclosure batch (part of the same PathSecurityValidator remediation): GHSA-gg2g-p7xc-qqmm (SSTI RCE), GHSA-g3vg-vx23-3858 (cache path traversal), GHSA-mj4x-vf5c-5xg8 (profile-import path traversal read), GHSA-w76h-q7c6-jpjp (SSRF)\n\n- Fix commits (jinja-only scope): 247fcce289f60103f3d8e28d8ec51a6986b94fb6, 7d107b3ac53caca7bde97a6278b23cd739d94525\n\n- Affected sinks: trestle/core/commands/author/catalog.py:69,90; author/ssp.py:97,111; author/prof.py:86,110; bypassed guard trestle/common/file_utils.py:95; write sink trestle/core/catalog/catalog_api.py:72; unused-here correct guard trestle/core/remote/security.py:326\n\n- Additional unguarded siblings: trestle/core/commands/create.py:95, trestle/core/commands/replicate.py:90","aliases":["CVE-2026-57171","GHSA-r4vp-3vw6-r2x5"],"modified":"2026-10-01T17:45:04.817354251Z","published":"2026-10-01T16:38:39.373361Z","references":[{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/security/advisories/GHSA-4q5v-7g7x-j79w"},{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/security/advisories/GHSA-r4vp-3vw6-r2x5"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-57171"},{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/pull/2270"},{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/commit/37ed44f5f2e074202c8eb7c2f203c05d19461cdc"},{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/commit/5335ff873a2a68eb7de43df029bea09cadff22fd"},{"type":"PACKAGE","url":"https://github.com/oscal-compass/compliance-trestle"},{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/releases/tag/v3.12.4"},{"type":"WEB","url":"https://github.com/oscal-compass/compliance-trestle/releases/tag/v4.1.0"},{"type":"PACKAGE","url":"https://pypi.org/project/compliance-trestle"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-r4vp-3vw6-r2x5"}],"affected":[{"package":{"name":"compliance-trestle","ecosystem":"PyPI","purl":"pkg:pypi/compliance-trestle"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.12.4"},{"introduced":"4.0.0"},{"fixed":"4.1.0"}]}],"versions":["0.0.2","0.0.3","0.1.0","0.1.1","0.10.0","0.11.0","0.12.0","0.13.0","0.13.1","0.14.0","0.14.1","0.14.2","0.14.3","0.14.4","0.15.0","0.15.1","0.16.0","0.17.0","0.18.0","0.18.1","0.19.0","0.2.0","0.2.1","0.2.2","0.20.0","0.21.0","0.22.0","0.22.1","0.23.0","0.24.0","0.25.0","0.25.1","0.26.0","0.27.0","0.27.1","0.27.2","0.28.0","0.28.1","0.29.0","0.3.0","0.30.0","0.31.0","0.32.0","0.32.1","0.33.0","0.34.0","0.35.0","0.36.0","0.37.0","0.4.0","0.5.0","0.6.0","0.6.1","0.6.2","0.7.0","0.7.1","0.7.2","0.8.0","0.8.1","0.9.0","1.0.0rc0","1.0.1","1.0.2","1.1.0","1.2.0","2.0.0","2.1.0","2.1.1","2.2.0","2.2.1","2.3.0","2.3.1","2.4.0","2.5.0","2.5.1","2.6.0","2.6.1","3.0.1","3.1.0","3.10.2","3.10.3","3.10.4","3.11.0","3.12.0","3.12.1","3.12.2","3.12.3","3.2.0","3.3.0","3.4.0","3.5.0","3.6.0","3.7.0","3.8.0","3.8.1","3.9.0","3.9.1","3.9.2","3.9.3","4.0.0","4.0.1","4.0.2","4.0.3"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/compliance-trestle/PYSEC-2026-4030.yaml"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]}