~/f4n6 $ grep -r "Trestle is vulnerable to arbitrary file write via path traversal in author generate commands (Incomplete fix of CVE-2026-46345)" ./investigations/ --include="*.md"

Trestle is vulnerable to arbitrary file write via path traversal in author generate commands (Incomplete fix of CVE-2026-46345)

Jeff Davies 24 Sep 2026 4 min read

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 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 →

Published via PulseTrace — Adverse Trace threat intelligence.

Post this to LinkedIn
Formatting is converted automatically — headings, bullets, a link back & hashtags. Paste straight in.
J
Jeff Davies