Skip to content

Commit 9698d7e

Browse files
authored
Merge commit from fork
Signed-off-by: degenaro <lou.degenaro@gmail.com>
1 parent 9a294ec commit 9698d7e

5 files changed

Lines changed: 369 additions & 12 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ warn_untyped_fields = true
295295

296296
[tool.coverage.run]
297297
relative_files = true
298+
omit = [
299+
"*/tmp*/*.j2",
300+
"*/tmp*/*.md.j2",
301+
]
298302

299303
[tool.semantic_release]
300304
build_command = """
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# -*- mode:python; coding:utf-8 -*-
2+
3+
# Copyright (c) 2026 The OSCAL Compass Authors. All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
"""Security tests for Jinja tags to verify SSTI vulnerability is fixed."""
17+
18+
import pathlib
19+
import tempfile
20+
21+
import pytest
22+
23+
from jinja2.sandbox import SandboxedEnvironment
24+
from jinja2.exceptions import SecurityError
25+
26+
from trestle.core.jinja.ext import extensions
27+
28+
29+
class TestJinjaTagsSecurity:
30+
"""Test security fixes for CVE-2026-46439 incomplete fix."""
31+
32+
def test_md_clean_include_allows_safe_variable_substitution(self):
33+
"""Test that md_clean_include allows safe variable substitution in sandboxed environment."""
34+
with tempfile.TemporaryDirectory() as tmpdir:
35+
tmpdir_path = pathlib.Path(tmpdir)
36+
37+
# Create a markdown file with safe Jinja variable substitution
38+
included_md = tmpdir_path / 'included.md'
39+
safe_content = '# Test\n\nThis has {{ safe_var }} in it.\n'
40+
included_md.write_text(safe_content)
41+
42+
# Create a template that includes the file
43+
template_file = tmpdir_path / 'template.md.j2'
44+
template_file.write_text('{% md_clean_include "included.md" %}')
45+
46+
# Render the template with a safe variable
47+
env = SandboxedEnvironment(loader=None, extensions=extensions(), trim_blocks=True, autoescape=True)
48+
49+
from jinja2 import FileSystemLoader
50+
51+
env.loader = FileSystemLoader(tmpdir_path)
52+
template = env.get_template('template.md.j2')
53+
result = template.render(safe_var='SUBSTITUTED')
54+
55+
# Verify safe variable substitution works
56+
assert 'SUBSTITUTED' in result
57+
assert 'This has' in result
58+
59+
def test_md_clean_include_blocks_dangerous_attribute_access(self):
60+
"""Test that md_clean_include blocks dangerous attribute access via sandbox."""
61+
with tempfile.TemporaryDirectory() as tmpdir:
62+
tmpdir_path = pathlib.Path(tmpdir)
63+
64+
# Create a markdown file with malicious Jinja code attempting RCE
65+
included_md = tmpdir_path / 'included.md'
66+
# Attempt to access dangerous attributes for RCE
67+
malicious_content = "# Test\n\n{{ ''.__class__.__mro__[1].__subclasses__() }}\n"
68+
included_md.write_text(malicious_content)
69+
70+
# Create a template that includes the file
71+
template_file = tmpdir_path / 'template.md.j2'
72+
template_file.write_text('{% md_clean_include "included.md" %}')
73+
74+
# Render the template - should raise SecurityError or similar
75+
env = SandboxedEnvironment(loader=None, extensions=extensions(), trim_blocks=True, autoescape=True)
76+
77+
from jinja2 import FileSystemLoader
78+
79+
env.loader = FileSystemLoader(tmpdir_path)
80+
template = env.get_template('template.md.j2')
81+
82+
# Should raise SecurityError when trying to access __class__
83+
with pytest.raises((SecurityError, Exception)):
84+
template.render()
85+
86+
def test_mdsection_include_allows_safe_variable_substitution(self):
87+
"""Test that mdsection_include allows safe variable substitution in sandboxed environment."""
88+
with tempfile.TemporaryDirectory() as tmpdir:
89+
tmpdir_path = pathlib.Path(tmpdir)
90+
91+
# Create a markdown file with a section containing safe variable
92+
included_md = tmpdir_path / 'included.md'
93+
safe_content = """# Section One
94+
95+
This section has {{ safe_var }} in it.
96+
97+
# Section Two
98+
99+
Another section.
100+
"""
101+
included_md.write_text(safe_content)
102+
103+
# Create a template that includes a specific section
104+
template_file = tmpdir_path / 'template.md.j2'
105+
template_file.write_text('{% mdsection_include "included.md" "# Section One" %}')
106+
107+
# Render the template with a safe variable
108+
env = SandboxedEnvironment(loader=None, extensions=extensions(), trim_blocks=True, autoescape=True)
109+
110+
from jinja2 import FileSystemLoader
111+
112+
env.loader = FileSystemLoader(tmpdir_path)
113+
template = env.get_template('template.md.j2')
114+
result = template.render(safe_var='SUBSTITUTED')
115+
116+
# Verify safe variable substitution works
117+
assert 'SUBSTITUTED' in result
118+
assert 'This section has' in result
119+
120+
def test_mdsection_include_blocks_dangerous_attribute_access(self):
121+
"""Test that mdsection_include blocks dangerous attribute access via sandbox."""
122+
with tempfile.TemporaryDirectory() as tmpdir:
123+
tmpdir_path = pathlib.Path(tmpdir)
124+
125+
# Create a markdown file with malicious code
126+
included_md = tmpdir_path / 'included.md'
127+
malicious_content = """# Section One
128+
129+
{{ ''.__class__.__mro__[1].__subclasses__() }}
130+
131+
# Section Two
132+
133+
Another section.
134+
"""
135+
included_md.write_text(malicious_content)
136+
137+
# Create a template that includes the malicious section
138+
template_file = tmpdir_path / 'template.md.j2'
139+
template_file.write_text('{% mdsection_include "included.md" "# Section One" %}')
140+
141+
# Render the template - should raise SecurityError
142+
env = SandboxedEnvironment(loader=None, extensions=extensions(), trim_blocks=True, autoescape=True)
143+
144+
from jinja2 import FileSystemLoader
145+
146+
env.loader = FileSystemLoader(tmpdir_path)
147+
template = env.get_template('template.md.j2')
148+
149+
# Should raise SecurityError when trying to access __class__
150+
with pytest.raises((SecurityError, Exception)):
151+
template.render()
152+
153+
def test_md_datestamp_does_not_execute_injected_code(self):
154+
"""Test that md_datestamp doesn't allow code injection through format strings."""
155+
with tempfile.TemporaryDirectory() as tmpdir:
156+
tmpdir_path = pathlib.Path(tmpdir)
157+
158+
# Create a template with datestamp
159+
template_file = tmpdir_path / 'template.md.j2'
160+
# Use a safe format string
161+
template_file.write_text('{% md_datestamp format="%Y-%m-%d" %}')
162+
163+
# Render the template
164+
env = SandboxedEnvironment(loader=None, extensions=extensions(), trim_blocks=True, autoescape=True)
165+
166+
from jinja2 import FileSystemLoader
167+
168+
env.loader = FileSystemLoader(tmpdir_path)
169+
template = env.get_template('template.md.j2')
170+
result = template.render()
171+
172+
# Verify we get a date, not code execution
173+
import re
174+
175+
assert re.match(r'\d{4}-\d{2}-\d{2}', result.strip())
176+
177+
def test_neutralization_in_ssp_io(self):
178+
"""Test that Jinja delimiters are neutralized in SSP prose/description."""
179+
from trestle.core.ssp_io import _neutralize_jinja_delimiters
180+
181+
# Test basic neutralization
182+
input_text = 'This has {{ variable }} and {{ another }}'
183+
expected = 'This has [[ variable ]] and [[ another ]]'
184+
assert _neutralize_jinja_delimiters(input_text) == expected
185+
186+
# Test empty/None handling
187+
assert _neutralize_jinja_delimiters('') == ''
188+
assert _neutralize_jinja_delimiters(None) is None
189+
190+
# Test text without delimiters
191+
plain_text = 'This is plain text'
192+
assert _neutralize_jinja_delimiters(plain_text) == plain_text
193+
194+
def test_neutralization_in_docs_control_writer(self):
195+
"""Test that Jinja delimiters are neutralized in control prose."""
196+
from trestle.core.docs_control_writer import _neutralize_jinja_delimiters
197+
198+
# Test basic neutralization
199+
input_text = 'Control prose with {{ param }} reference'
200+
expected = 'Control prose with [[ param ]] reference'
201+
assert _neutralize_jinja_delimiters(input_text) == expected
202+
203+
# Test empty/None handling
204+
assert _neutralize_jinja_delimiters('') == ''
205+
assert _neutralize_jinja_delimiters(None) is None
206+
207+
def test_no_code_execution_with_malicious_oscal_data(self):
208+
"""Integration test: verify malicious OSCAL-like data doesn't execute."""
209+
with tempfile.TemporaryDirectory() as tmpdir:
210+
tmpdir_path = pathlib.Path(tmpdir)
211+
212+
# Simulate markdown generated from OSCAL with malicious content
213+
# This represents what would be written by ssp_io or docs_control_writer
214+
generated_md = tmpdir_path / 'generated.md'
215+
# After neutralization, this should have [[ ]] not {{ }}
216+
neutralized_content = """# Control AC-1
217+
218+
## Control Statement
219+
220+
The organization shall [[ insert: assignment ]] establish policies.
221+
222+
## Implementation
223+
224+
Component description: [[ cycler.__init__.__globals__.os.popen('id').read() ]]
225+
"""
226+
generated_md.write_text(neutralized_content)
227+
228+
# Template that includes the generated markdown
229+
template_file = tmpdir_path / 'template.md.j2'
230+
template_file.write_text("""# System Security Plan
231+
232+
{% md_clean_include "generated.md" %}
233+
""")
234+
235+
# Render the template
236+
env = SandboxedEnvironment(loader=None, extensions=extensions(), trim_blocks=True, autoescape=True)
237+
238+
from jinja2 import FileSystemLoader
239+
240+
env.loader = FileSystemLoader(tmpdir_path)
241+
template = env.get_template('template.md.j2')
242+
result = template.render()
243+
244+
# Verify malicious code appears literally, not executed
245+
assert '[[ insert: assignment ]]' in result
246+
assert '[[ cycler.__init__.__globals__.os.popen' in result
247+
# Verify no command output appears (would indicate execution)
248+
assert 'uid=' not in result # Common output from 'id' command
249+
assert 'gid=' not in result
250+
251+
252+
if __name__ == '__main__':
253+
pytest.main([__file__, '-v'])
254+
255+
# Made with Bob

trestle/core/docs_control_writer.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@
2929
logger = logging.getLogger(__name__)
3030

3131

32+
def _neutralize_jinja_delimiters(text: str) -> str:
33+
"""Neutralize Jinja2 template delimiters to prevent SSTI attacks.
34+
35+
Replaces {{ and }} with [[ and ]] to prevent untrusted OSCAL data
36+
from being interpreted as Jinja2 template code when included in
37+
markdown files that are later processed by Jinja2 include tags.
38+
39+
This is a defense-in-depth measure to complement the primary fix
40+
of not re-parsing included content as templates.
41+
42+
Args:
43+
text: The text to neutralize
44+
45+
Returns:
46+
Text with Jinja delimiters replaced
47+
"""
48+
if not text:
49+
return text
50+
return text.replace('{{', '[[').replace('}}', ']]')
51+
52+
3253
class DocsControlWriter(ControlWriter):
3354
"""Class to write controls as markdown for docs purposes."""
3455

@@ -194,7 +215,8 @@ def _add_one_section(
194215
if tag_pattern:
195216
self._md_file.new_line(tag_pattern.replace('[.]', heading_title.replace(' ', '-').lower()))
196217
self._md_file.new_paragraph()
197-
self._md_file.new_line(prose)
218+
# SECURITY: Neutralize Jinja delimiters in prose to prevent SSTI
219+
self._md_file.new_line(_neutralize_jinja_delimiters(prose))
198220
self._md_file.new_paragraph()
199221
else:
200222
# write parts and subparts if exist
@@ -223,7 +245,8 @@ def _write_part_info(
223245
self._md_file.new_line(tag_pattern.replace('[.]', tag_section_name))
224246
self._md_file.new_paragraph()
225247
prose = '' if part_info.prose is None else part_info.prose
226-
self._md_file.new_line(prose)
248+
# SECURITY: Neutralize Jinja delimiters in prose to prevent SSTI
249+
self._md_file.new_line(_neutralize_jinja_delimiters(prose))
227250
self._md_file.new_paragraph()
228251

229252
for subpart_info in as_list(part_info.parts):

0 commit comments

Comments
 (0)