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:
- The attacker gains influence over the
-ovalue passed totrestle author catalog-generate,profile-generateorssp-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.) - The command builds the output root as
markdown_path = trestle_root / args.output(catalog.py:90,prof.py:110;ssp.py:111uses the variable namemd_path). The raw CLI string is joined with no sanitisation. (Source-confirmed.) - The only check applied is
is_directory_name_allowed()(catalog.py:69,ssp.py:97,prof.py:86). It inspectsparts[0]only and rejects four things: a first component matching an OSCAL model directory name (catalogs,profiles, and the rest ofMODEL_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.) - Two payloads pass the check. An absolute path such as
-o /tmp/pwned, whereparts[0]is/andpathlibdiscards the left operand on join. And a non-leading traversal such as-o subdir/../../../../../../tmp/pwned, whereparts[0]is the innocuoussubdirand the..segments resolve out of the workspace at write time. The naive-o ../../tmp/pwnedis the only form the check stops. (Source-confirmed, and isolated by importing the guard's semantics.) - 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()callsself._context.md_root.mkdir(exist_ok=True, parents=True)(trestle/core/catalog/catalog_api.py:72) and writes<control-id>.mdfiles beneath it. A grep forresolve(),relative_toorvalidate_local_pathacross the catalog write path returns zero hits. (Source-confirmed.) - Files land outside the trestle workspace as the invoking process owner. Runtime-confirmed for
catalog-generateon a v4.0.3 install (HEADe22e35bd):trestle author catalog-generate -n mycat -o /tmp/TRESTLE_ESCAPE_ABSproduced/tmp/TRESTLE_ESCAPE_ABS/grp1/ac-1.mdwhile the workspace/tmp/poc_wscontained no such directory. The same escape was reproduced with-o 'subdir/../../../../../../tmp/TRESTLE_ESCAPE_DOTDOT'. - Destructive variant. With
--force-overwrite, the force-overwrite path clears the selected output directory viaclear_folder(...)intrestle/core/commands/common/cmd_utils.py, which callsshutil.rmtreeon that directory. Becauseclear_folderearly-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… |
Published via PulseTrace — Adverse Trace threat intelligence.