> ## Content Index
> Fetch the complete content index at: https://f4n6.co.uk/llms.txt
> Use this file to discover other available public pages before exploring further.

# Trestle is vulnerable to arbitrary file write via path traversal in author generate commands (Incomplete fix of CVE-2026-46345)
- URL: https://f4n6.co.uk/security-feed/trestle-is-vulnerable-to-arbitrary-file-write-via-path-traversal-in-author-generate-commands-incomplete-fix-of-cve-2026-46345/
- Published: 2026-09-24T22:55:04.000Z
- Updated: 2026-09-24T22:55:04.000Z
- Author: Jeff Davies
- Tags: #security-feed, CVE-2026-46345

## 1\. Executive summary

The fix shipped for CVE-2026-46345 (CVSS 8.4 HIGH, not in CISA KEV, EPSS 0%) added a path-traversal guard to `trestle author jinja` but left the identical output-path construction in `trestle author catalog-generate`, `profile-generate` and `ssp-generate` unguarded, so an attacker who controls the `-o` value can write generated markdown anywhere the trestle process can write. The write escape is runtime-confirmed for `catalog-generate` on v4.0.3; the other two commands are source-confirmed siblings with the same join and the same missing guard. Exposure is limited to organisations that run these subcommands in CI, a shared trestle service or a wrapper where the output directory is derived from repository-controlled or tenant-controlled data; a local operator choosing their own `-o` is not the threat model. With `--force-overwrite` the attacker-selected directory is recursively deleted via `shutil.rmtree` before generation, which adds an availability impact. No exploitation in the wild is reported and the sources contain no atomic indicators.

## 2\. Regulatory framing

| Article                                                | Trigger (the fact in this item)                                                                                                                                                                                                  | Practical impact                                                                                                                                                                                                                                         |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| NIS2 Art. 21(2)(d): supply chain security measures     | The affected component is a build-pipeline dependency whose generate subcommands accept an output path derived from repository-controlled data, so a compromised repository can write outside the workspace on the build runner. | Treat the CI job that invokes trestle as a supply-chain boundary. Stop deriving \-o from repository or tenant data, or validate the resolved path against an allowlist before invocation, and constrain the runner user's write access to the workspace. |
| DORA Art. 28: ICT third-party risk, general principles | The vendor's remediation for the parent CVE is incomplete: the guard exists in jinja.py but was never applied to the sibling generate commands, so "upgrade to the latest release" is not evidence that this code path is fixed. | Third-party assurance for this component has to be based on the specific code path, not the version number. Record the residual gap against the component and re-test after any vendor release rather than closing it on version evidence.               |

No incident has occurred and no major ICT-related incident reporting obligation is engaged, so DORA Art. 17, 18 and 19 are not cited here.

## 3\. Technical analysis & attack chain

Confirmed steps, with the confidence level for each:

1. The attacker gains influence over the `-o` value passed to `trestle author catalog-generate`, `profile-generate` or `ssp-generate`. The source's threat model is a CI workflow, shared trestle service or wrapper that derives the output directory from repository-controlled, tenant-controlled or otherwise untrusted data. No shell access to the trestle host is required. (Source-stated threat model.)
2. The command builds the output root as `markdown_path = trestle_root / args.output` (`catalog.py:90`, `prof.py:110`; `ssp.py:111` uses the variable name `md_path`). The raw CLI string is joined with no sanitisation. (Source-confirmed.)
3. The only check applied is `is_directory_name_allowed()` (`catalog.py:69`, `ssp.py:97`, `prof.py:86`). It inspects `parts[0]` only and rejects four things: a first component matching an OSCAL model directory name (`catalogs`, `profiles`, and the rest of `MODEL_TYPE_TO_MODEL_DIR`), a leading `.`, a name carrying a file suffix, and any `__global__` component. It does not reject absolute paths, and it does not reject `..` components that are not first. (Source-confirmed.)
4. Two payloads pass the check. An absolute path such as `-o /tmp/pwned`, where `parts[0]` is `/` and `pathlib` discards the left operand on join. And a non-leading traversal such as `-o subdir/../../../../../../tmp/pwned`, where `parts[0]` is the innocuous `subdir` and the `..` segments resolve out of the workspace at write time. The naive `-o ../../tmp/pwned` is the only form the check stops. (Source-confirmed, and isolated by importing the guard's semantics.)
5. The value flows unchanged to the write sink. `ControlContext.generate(..., md_root=markdown_path, ...)` stores it as a dataclass field (`trestle/core/control_context.py:46`); `CatalogAPI.write_catalog_as_markdown()` calls `self._context.md_root.mkdir(exist_ok=True, parents=True)` (`trestle/core/catalog/catalog_api.py:72`) and writes `<control-id>.md` files beneath it. A grep for `resolve()`, `relative_to` or `validate_local_path` across the catalog write path returns zero hits. (Source-confirmed.)
6. Files land outside the trestle workspace as the invoking process owner. Runtime-confirmed for `catalog-generate` on a v4.0.3 install (HEAD `e22e35bd`): `trestle author catalog-generate -n mycat -o /tmp/TRESTLE_ESCAPE_ABS` produced `/tmp/TRESTLE_ESCAPE_ABS/grp1/ac-1.md` while the workspace `/tmp/poc_ws` contained no such directory. The same escape was reproduced with `-o 'subdir/../../../../../../tmp/TRESTLE_ESCAPE_DOTDOT'`.
7. Destructive variant. With `--force-overwrite`, the force-overwrite path clears the selected output directory via `clear_folder(...)` in `trestle/core/commands/common/cmd_utils.py`, which calls `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, not pinpoint deletion of a single file. (Source-confirmed; the published PoC exercises the write escape, not `-fo`.)

The missing guard is `PathSecurityValidator.validate_local_path()` (`trestle/core/remote/security.py:326`), which resolves the path and calls `relative_to(trestle_root)`, rejecting both `..` traversal and absolute paths. It is wired into `jinja.py` at lines 229, 278 and 297 by commits `247fcce289f60103f3d8e28d8ec51a6986b94fb6` and `7d107b3ac53caca7bde97a6278b23cd739d94525`, both of which touch only `jinja.py` and its tests.

The differential control is what establishes this as an incomplete fix rather than an unpatched version. On the same v4.0.3 install, `trestle author jinja -i template.j2 -o '/tmp/TRESTLE_JINJA_BLOCKED'` fails with `Security violation: Path traversal blocked. Attempted to access "/tmp/TRESTLE_JINJA_BLOCKED" which is outside the trestle workspace "/tmp/poc_ws"` and writes nothing, while `catalog-generate` with an equivalent `-o` escapes. The guard exists and works in `jinja`; it is simply absent from the generate commands.

OSCAL id validation does not close this. `control.id` and `group.id` are NCName-validated and cannot contain `/`, `\` or a leading `..`, so the per-control leaf filenames such as `grp1/ac-1.md` cannot themselves traverse. The escape happens at the base root `md_root = trestle_root / args.output`, which is built from the raw, unconstrained CLI string before those safe leaves are appended. In the PoC the escape occurs at `md_root` (`/tmp/TRESTLE_ESCAPE_ABS`) with `grp1/ac-1.md` written beneath it.

Two further sinks share the root cause. `trestle 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 or `..` `--output`. They write a structured OSCAL model file and refuse to overwrite an existing target (`.exists()` raises), so the primitive there is create-only. The source flags these as defence-in-depth sinks, not as part of the primary impact claim.

Severity. The source states the metrics mirror the parent CVE-2026-46345 (8.4, \`

## CVE assessment

1 referenced CVE

| CVE                                                                              | CVSS     | Exploited | EPSS | Summary                                                                                                                         |
| -------------------------------------------------------------------------------- | -------- | --------- | ---- | ------------------------------------------------------------------------------------------------------------------------------- |
| [CVE-2026-46345](https://nvd.nist.gov/vuln/detail/CVE-2026-46345?ref=f4n6.co.uk) | 8.4 High | —         | 0%   | compliance-trestle is a tooling platform for managing compliance as code. Prior to versions 3.12.2 and 4.0.3, the \-o/--output… |

---

[Read the original source →](https://github.com/advisories/GHSA-r4vp-3vw6-r2x5?ref=f4n6.co.uk)

*Published via PulseTrace — Adverse Trace threat intelligence.*