Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- tolerate picklescan reports that omit private metadata
- 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
- treat canonical storage persistent IDs as informational only after legacy PyTorch framing, storage tuples, and storage payloads validate completely
- prefer validated SafeTensors framing over invalid pickle and weak-magic collisions while retaining security-bearing pickle overlaps
- recognize current GGUF IQ3_S, IQ4_XS, and BF16 tensor types while preserving bounded inconclusive handling for unsupported or malformed tensor metadata
Expand Down
169 changes: 163 additions & 6 deletions modelaudit/scanners/jinja2_template_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1426,6 +1438,150 @@ 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,
"#}",
quote_aware=False,
)
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,
*,
quote_aware: bool = True,
) -> 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 quote_aware and 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,
"%}",
quote_aware=False,
)
block_text = template_content[block_start:block_end]
if Jinja2TemplateScanner._jinja_block_tag_name(block_text) == "endraw":
return block_end
cursor = 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()
Expand Down Expand Up @@ -1644,10 +1800,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:
Expand Down
Loading
Loading