From 281f15e114b3907f84525a3e6e143c63b59a755a Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Thu, 5 Nov 2026 23:12:14 +0530 Subject: [PATCH 01/11] Bound plain JavaScript literal parser failures to their native values Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 56 +++++++++- .../analyzers/static_patterns_tool_misuse.py | 56 ++++++++-- .../test_javascript_template_ownership.py | 104 ++++++++++++++++++ 3 files changed, 203 insertions(+), 13 deletions(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index 202c357f0..0e7ea8a89 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1499,6 +1499,7 @@ def javascript_template_spans( check_runtime: Callable[[], None], *, unresolved_executables: list[int] | None = None, + quoted_strings: list[tuple[int, int]] | None = None, ) -> JavaScriptTemplateSpans | None: """Prove host delimiters while retaining unresolved executable templates. @@ -1507,13 +1508,17 @@ def javascript_template_spans( command templates retain the ordinary bounded shell inspection instead of gaining a delimiter exemption. Raw finding checks always keep the source. """ - if "`" not in content: + if "`" not in content and ( + quoted_strings is None or not any(quote in content for quote in "\"'") + ): return None program = parse_javascript_source(content, check_runtime) if program is None: if unresolved_executables is not None: unresolved_executables.append(content.index("`")) return None + if quoted_strings is not None: + quoted_strings.extend(_plain_quoted_value_spans(program, check_runtime)) templates = [node for node in program.nodes if node.type == "template_string"] if not templates: return None @@ -1561,6 +1566,55 @@ def javascript_template_spans( return tuple(starts), tuple(ends) +def _plain_quoted_value_spans( + program: JavaScriptProgram, check_runtime: Callable[[], None] +) -> list[tuple[int, int]]: + """Bound plain literal values; do not infer escaped or extended code.""" + spans: list[tuple[int, int]] = [] + extensions = { + "binary_expression", + "augmented_assignment_expression", + "member_expression", + "subscript_expression", + "call_expression", + "new_expression", + "template_substitution", + "import_statement", + "export_statement", + } + boundaries = { + "program", + "expression_statement", + "return_statement", + "lexical_declaration", + "variable_declarator", + "for_in_statement", + } + for node in program.nodes: + check_runtime() + if node.type != "string": + continue + start, end = program.span(node) + value = program.content[start + 1 : end - 1] + if ( + end - start < 2 + or program.content[start] not in "\"'" + or program.content[end - 1] != program.content[start] + or "\\" in value + or "$" in value + ): + continue + parent = node.parent + while parent is not None and parent.type not in boundaries: + check_runtime() + if parent.type in extensions: + break + parent = parent.parent + else: + spans.append((start, end)) + return spans + + def _scalar_template_identifiers( program: JavaScriptProgram, check_runtime: Callable[[], None] ) -> set[int]: diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index f8702c868..073274d45 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -3369,6 +3369,28 @@ def _has_shell_command_word_exhaustion( quoted_printf_allowed = _quoted_printf_allowed and not _shell_redefines_printf( content, check_runtime ) + + def candidate_is_complete(start: int) -> bool: + if _quoted_candidate_is_complete( + content, start, quote_limits, check_runtime, quoted_printf_allowed + ): + return True + if javascript_source is None or _command_string_depth >= 8: + return False + return javascript_source.quoted_string_is_complete( + start, + lambda value: ( + _has_shell_command_word_exhaustion( + value, + check_runtime, + _command_string_depth=_command_string_depth + 1, + _quoted_printf_allowed=quoted_printf_allowed, + complete_context=False, + ) + is None + ), + ) + parsed_through = 0 # A word that spans lines may pair a heredoc, comment or prose quote with # a later command line. Main skips the candidates inside such a word, so @@ -3528,9 +3550,7 @@ def _has_shell_command_word_exhaustion( substitution_end, check_runtime=check_runtime, ): - if not _quoted_candidate_is_complete( - content, start, quote_limits, check_runtime, quoted_printf_allowed - ): + if not candidate_is_complete(start): return ( word_diagnostics[0] if word_diagnostics @@ -3602,9 +3622,7 @@ def _has_shell_command_word_exhaustion( # expressions cannot continue a shell word into later code. unresolved_end = min(unresolved_end, javascript_source.word_end(start)) if unresolved_end - start > _SHELL_COMMAND_WORD_CHARS: - if not _quoted_candidate_is_complete( - content, start, quote_limits, check_runtime, quoted_printf_allowed - ): + if not candidate_is_complete(start): return ( word_diagnostics[0] if word_diagnostics @@ -3690,9 +3708,7 @@ def _has_shell_command_word_exhaustion( # spans and runtime-selected operands keep their existing # conservative treatment. continue - if not _quoted_candidate_is_complete( - content, start, quote_limits, check_runtime, quoted_printf_allowed - ): + if not candidate_is_complete(start): assert parsed.diagnostic is not None return parsed.diagnostic operand_diagnostic = ( @@ -3710,9 +3726,7 @@ def _has_shell_command_word_exhaustion( if operand_diagnostic is not None: comment = python_source.comment(start) if python_source is not None else None if comment is None: - if _quoted_candidate_is_complete( - content, start, quote_limits, check_runtime, quoted_printf_allowed - ): + if candidate_is_complete(start): continue # Host code after a Python string's closing quote is not shell # text, so a command in the string takes operands only from @@ -5796,6 +5810,9 @@ def __init__(self, content: str, check_runtime: Callable[[], None]) -> None: self._spans: JavaScriptTemplateSpans | None = None self._spans_computed = False self._unresolved_executables: list[int] = [] + self._quoted_strings: list[tuple[int, int]] = [] + self._quoted_starts: list[int] = [] + self._quoted_complete: dict[int, bool] = {} def _owned_spans(self) -> JavaScriptTemplateSpans | None: if not self._spans_computed: @@ -5803,7 +5820,9 @@ def _owned_spans(self) -> JavaScriptTemplateSpans | None: self._content, self._check_runtime, unresolved_executables=self._unresolved_executables, + quoted_strings=self._quoted_strings, ) + self._quoted_starts = [start for start, _ in self._quoted_strings] self._spans_computed = True return self._spans @@ -5820,6 +5839,19 @@ def unresolved_execution_diagnostic(self) -> StaticParserDiagnostic | None: ParserUncheckedScope.REMAINING_INPUT, ) + def quoted_string_is_complete(self, position: int, check_value: Callable[[str], bool]) -> bool: + """Refine one failed candidate only after checking its whole value.""" + self._owned_spans() + index = bisect_right(self._quoted_starts, position) - 1 + if index < 0: + return False + start, end = self._quoted_strings[index] + if not start < position < end - 1: + return False + if start not in self._quoted_complete: + self._quoted_complete[start] = check_value(self._content[start + 1 : end - 1]) + return self._quoted_complete[start] + def owns(self, position: int) -> bool: spans = self._owned_spans() if spans is None: diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index ade54aee6..e6ba6acbf 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -854,3 +854,107 @@ def test_known_native_callee_cannot_hide_security_findings() -> None: assert any( finding.rule_id == "TM1" for finding in tm.analyze(source, "display.js", "javascript") ) + + +def test_plain_quoted_values_expose_native_boundaries_without_becoming_templates() -> None: + source = "const values = ['`first` before', \"`second` after\"];\n" + quoted: list[tuple[int, int]] = [] + assert javascript_template_spans(source, lambda: None, quoted_strings=quoted) is None + assert [source[start:end] for start, end in quoted] == [ + "'`first` before'", + '"`second` after"', + ] + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_quoted_backtick_values_cannot_consume_later_host_code(newline: str) -> None: + source = newline.join( + [ + "const values = [['`first` before', '`first` after']];", + "// " + ("padding " * 1000), + "const other = ['`second` before', '`second` after'];", + ] + ) + assert not tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="javascript") + + +@pytest.mark.parametrize( + "suffix", + [ + "exec(`${tool} -rf /`);", + "new Function(`${code}`);", + "import {exec} from 'node:child_process'; exec(`${command}`);", + "const other = ['`$TOOL -rf /`'];", + ], +) +def test_completed_quoted_value_never_clears_later_runtime_uncertainty(suffix: str) -> None: + source = "const values = ['`name` before', '`name` after'];\n" + suffix + assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="javascript") + + +def test_quoted_value_refinement_preserves_raw_high_commands() -> None: + source = ( + "const values = ['`name` before', '`name` after'];\n" + "import {execSync} from 'node:child_process'; execSync('rm -rf /');\n" + ) + assert any( + finding.rule_id == "TM1" for finding in tm.analyze(source, "control.mjs", "javascript") + ) + + +@pytest.mark.parametrize( + "source", + [ + r"const value = ['`name`\n'];", + "const value = ['`$TOOL -rf /`'];", + "const value = ['`name`' + extra];", + "const value = ['`name`'.concat(extra)];", + "eval('`name`');", + "new Function('`name`');", + "unknown('`name`');", + "const value = ['`name`];", + ], +) +def test_unproved_quoted_values_do_not_receive_boundary_refinement(source: str) -> None: + quoted: list[tuple[int, int]] = [] + javascript_template_spans(source, lambda: None, quoted_strings=quoted) + assert quoted == [] + + +def test_quoted_value_boundaries_keep_unicode_character_offsets() -> None: + source = "const label = '日本語'; const values = ['`café` before'];" + quoted: list[tuple[int, int]] = [] + javascript_template_spans(source, lambda: None, quoted_strings=quoted) + assert [source[start:end] for start, end in quoted] == ["'日本語'", "'`café` before'"] + + +def test_quoted_body_is_checked_once_and_does_not_own_its_delimiters() -> None: + source = "const values = ['`name` before'];" + owner = tm._JavaScriptTemplateOwnership(source, lambda: None) + seen: list[str] = [] + + def checked(value: str) -> bool: + seen.append(value) + return True + + start = source.index("'") + end = source.rindex("'") + assert not owner.quoted_string_is_complete(start, checked) + assert owner.quoted_string_is_complete(start + 1, checked) + assert owner.quoted_string_is_complete(start + 3, checked) + assert not owner.quoted_string_is_complete(end, checked) + assert not owner.quoted_string_is_complete(len(source) - 1, checked) + assert seen == ["`name` before"] + + +def test_interrupted_quoted_body_check_is_not_cached() -> None: + source = "const values = ['`name` before'];" + owner = tm._JavaScriptTemplateOwnership(source, lambda: None) + position = source.index("`") + + def cancelled(value: str) -> bool: + raise TimeoutError("cancelled") + + with pytest.raises(TimeoutError, match="cancelled"): + owner.quoted_string_is_complete(position, cancelled) + assert owner.quoted_string_is_complete(position, lambda value: True) From 7b456db6ff5c1351af3bad084acdefb07641ee61 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Thu, 5 Nov 2026 23:12:34 +0530 Subject: [PATCH 02/11] Keep malformed ordinary literals on the conservative fallback Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 2 +- tests/nodes/analyzers/test_javascript_template_ownership.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index 0e7ea8a89..0371ee324 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1514,7 +1514,7 @@ def javascript_template_spans( return None program = parse_javascript_source(content, check_runtime) if program is None: - if unresolved_executables is not None: + if unresolved_executables is not None and "`" in content: unresolved_executables.append(content.index("`")) return None if quoted_strings is not None: diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index e6ba6acbf..7dc99e974 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -913,6 +913,7 @@ def test_quoted_value_refinement_preserves_raw_high_commands() -> None: "new Function('`name`');", "unknown('`name`');", "const value = ['`name`];", + "const value = ['unterminated];", ], ) def test_unproved_quoted_values_do_not_receive_boundary_refinement(source: str) -> None: From 045971cd68dd1a8d062bcce93e9f08cbba0e7170 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Thu, 5 Nov 2026 23:20:54 +0530 Subject: [PATCH 03/11] Preserve JavaScript fast path and existing runtime diagnostics Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 6 +-- .../test_javascript_template_ownership.py | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index 0371ee324..54f8a1d4b 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1508,13 +1508,11 @@ def javascript_template_spans( command templates retain the ordinary bounded shell inspection instead of gaining a delimiter exemption. Raw finding checks always keep the source. """ - if "`" not in content and ( - quoted_strings is None or not any(quote in content for quote in "\"'") - ): + if "`" not in content: return None program = parse_javascript_source(content, check_runtime) if program is None: - if unresolved_executables is not None and "`" in content: + if unresolved_executables is not None: unresolved_executables.append(content.index("`")) return None if quoted_strings is not None: diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index 7dc99e974..eff5474a3 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -959,3 +959,50 @@ def cancelled(value: str) -> bool: with pytest.raises(TimeoutError, match="cancelled"): owner.quoted_string_is_complete(position, cancelled) assert owner.quoted_string_is_complete(position, lambda value: True) + + +@pytest.mark.parametrize( + "source", + [ + "const value = '`name`'; eval(`${value}${payload}`);", + "let value = '`name`'; value += `${payload}`; Function(value)();", + "const values = ['`name`']; eval(`${values[0]}${payload}`);", + "import {exec as launch} from 'node:child_process'; " + "const value = '`name`'; launch(`${value}${payload}`);", + ], +) +def test_quoted_refinement_preserves_established_indirect_runtime_diagnostics( + source: str, monkeypatch +) -> None: + with monkeypatch.context() as baseline: + baseline.setattr( + tm._JavaScriptTemplateOwnership, "quoted_string_is_complete", lambda *args: False + ) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert before is not None + assert before.cause is tm.ParserCause.RUNTIME_DEPENDENT + assert after == before + + +def test_quoted_refinement_continues_to_later_overbudget_ordinary_string() -> None: + prefix = "const values = ['`name` before', '`name` after'];\n" + source = prefix + "const other = '$(" + ("a" * 5000) + "';\n" + diagnostic = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert diagnostic is not None + assert diagnostic.start >= len(prefix) + + +def test_plain_quoted_files_keep_no_backtick_fast_path(monkeypatch) -> None: + from skillspector import javascript_tokens + + def must_not_parse(*args): + raise AssertionError("no-backtick source should not require a native parse") + + monkeypatch.setattr(javascript_tokens, "parse_javascript_source", must_not_parse) + quoted: list[tuple[int, int]] = [] + assert ( + javascript_template_spans("const label = 'ordinary';", lambda: None, quoted_strings=quoted) + is None + ) + assert quoted == [] From 6a75f9b7fcf539d6f7c2ea609436c407e68283b3 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Fri, 6 Nov 2026 00:48:48 +0530 Subject: [PATCH 04/11] Bound JavaScript regex candidates by native literal ranges Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 78 +++++++++++++++++++ .../analyzers/static_patterns_tool_misuse.py | 21 +++++ .../test_javascript_template_ownership.py | 68 ++++++++++++++-- 3 files changed, 160 insertions(+), 7 deletions(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index 54f8a1d4b..f6bb577db 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1500,6 +1500,7 @@ def javascript_template_spans( *, unresolved_executables: list[int] | None = None, quoted_strings: list[tuple[int, int]] | None = None, + regex_literals: list[tuple[int, int]] | None = None, ) -> JavaScriptTemplateSpans | None: """Prove host delimiters while retaining unresolved executable templates. @@ -1517,6 +1518,8 @@ def javascript_template_spans( return None if quoted_strings is not None: quoted_strings.extend(_plain_quoted_value_spans(program, check_runtime)) + if regex_literals is not None: + regex_literals.extend(_plain_regex_value_spans(program, check_runtime)) templates = [node for node in program.nodes if node.type == "template_string"] if not templates: return None @@ -1613,6 +1616,81 @@ def _plain_quoted_value_spans( return spans +def _plain_regex_value_spans( + program: JavaScriptProgram, check_runtime: Callable[[], None] +) -> list[tuple[int, int]]: + """Keep native matching syntax separate from executable string consumers.""" + for node in program.nodes: + check_runtime() + property_node = node.child_by_field_name("property") or node.child_by_field_name("index") + if node.type in {"member_expression", "subscript_expression"} and ( + _callable_member(program, node) == "source" + or property_node is not None + and "\\" in program.text(property_node) + ): + # Source extraction needs a separate use proof before a pattern + # can stop lending boundaries to executable text. + return [] + matching = {"match", "matchAll", "search", "split", "replace", "replaceAll", "test", "exec"} + boundaries = { + "program", + "expression_statement", + "return_statement", + "variable_declarator", + "for_in_statement", + } + spans: list[tuple[int, int]] = [] + for node in program.nodes: + check_runtime() + if node.type != "regex": + continue + pattern = node.child_by_field_name("pattern") + flags = node.child_by_field_name("flags") + flag_text = program.text(flags) if flags is not None else "" + if ( + pattern is None + or len(set(flag_text)) != len(flag_text) + or set(flag_text) - set("dgimsuvy") + or {"u", "v"} <= set(flag_text) + ): + continue + start, end = program.span(node) + pattern_end = program.span(pattern)[1] + if ( + program.content[start : start + 1] != "/" + or program.content[pattern_end : pattern_end + 1] != "/" + ): + continue + parent = node.parent + while parent is not None and parent.type not in boundaries: + check_runtime() + if parent.type in { + "binary_expression", + "augmented_assignment_expression", + "new_expression", + "template_substitution", + "export_statement", + }: + break + if ( + parent.type in {"member_expression", "subscript_expression"} + and _callable_member(program, parent) not in matching + ): + break + if parent.type == "call_expression": + callee = parent.child_by_field_name("function") + if ( + callee is None + or callee.type != "member_expression" + or _callable_member(program, callee) not in matching + ): + break + parent = parent.parent + else: + spans.append((start, end)) + return spans + + def _scalar_template_identifiers( program: JavaScriptProgram, check_runtime: Callable[[], None] ) -> set[int]: diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index 073274d45..e724176d4 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -3528,6 +3528,13 @@ def candidate_is_complete(start: int) -> bool: quoted_printf_allowed=quoted_printf_allowed, diagnostics=word_diagnostics, ) + regex_end = ( + javascript_source.regex_literal_end(start) if javascript_source is not None else None + ) + if regex_end is not None and (parsed is None or parsed.limited or parsed.end > regex_end): + # RegExp backticks belong to matching syntax; a failed shell read + # cannot acquire ownership of later JavaScript through them. + continue if parsed is None: substitution_start = ( start + 1 @@ -5813,6 +5820,8 @@ def __init__(self, content: str, check_runtime: Callable[[], None]) -> None: self._quoted_strings: list[tuple[int, int]] = [] self._quoted_starts: list[int] = [] self._quoted_complete: dict[int, bool] = {} + self._regex_literals: list[tuple[int, int]] = [] + self._regex_starts: list[int] = [] def _owned_spans(self) -> JavaScriptTemplateSpans | None: if not self._spans_computed: @@ -5821,7 +5830,9 @@ def _owned_spans(self) -> JavaScriptTemplateSpans | None: self._check_runtime, unresolved_executables=self._unresolved_executables, quoted_strings=self._quoted_strings, + regex_literals=self._regex_literals, ) + self._regex_starts = [start for start, _ in self._regex_literals] self._quoted_starts = [start for start, _ in self._quoted_strings] self._spans_computed = True return self._spans @@ -5839,6 +5850,16 @@ def unresolved_execution_diagnostic(self) -> StaticParserDiagnostic | None: ParserUncheckedScope.REMAINING_INPUT, ) + def regex_literal_end(self, position: int) -> int | None: + """Bound a matching-language candidate by its complete native literal.""" + self._owned_spans() + index = bisect_right(self._regex_starts, position) - 1 + if index >= 0: + start, end = self._regex_literals[index] + if start <= position < end: + return end + return None + def quoted_string_is_complete(self, position: int, check_value: Callable[[str], bool]) -> bool: """Refine one failed candidate only after checking its whole value.""" self._owned_spans() diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index eff5474a3..a82b4fd0f 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -884,7 +884,6 @@ def test_quoted_backtick_values_cannot_consume_later_host_code(newline: str) -> "exec(`${tool} -rf /`);", "new Function(`${code}`);", "import {exec} from 'node:child_process'; exec(`${command}`);", - "const other = ['`$TOOL -rf /`'];", ], ) def test_completed_quoted_value_never_clears_later_runtime_uncertainty(suffix: str) -> None: @@ -985,12 +984,17 @@ def test_quoted_refinement_preserves_established_indirect_runtime_diagnostics( assert after == before -def test_quoted_refinement_continues_to_later_overbudget_ordinary_string() -> None: - prefix = "const values = ['`name` before', '`name` after'];\n" - source = prefix + "const other = '$(" + ("a" * 5000) + "';\n" - diagnostic = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") - assert diagnostic is not None - assert diagnostic.start >= len(prefix) +@pytest.mark.parametrize("value", ["`$TOOL -rf /`", "$(" + "a" * 5000]) +def test_ordinary_quoted_data_keeps_its_existing_complete_result(value, monkeypatch) -> None: + source = "const values = ['`name` before', '`name` after'];\nconst other = '" + value + "';" + with monkeypatch.context() as baseline: + baseline.setattr( + tm._JavaScriptTemplateOwnership, "quoted_string_is_complete", lambda *args: False + ) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert before is None + assert after is None def test_plain_quoted_files_keep_no_backtick_fast_path(monkeypatch) -> None: @@ -1006,3 +1010,53 @@ def must_not_parse(*args): is None ) assert quoted == [] + + +_JS_REGEX_BOUNDARY_SOURCE = ( + "const captured = text.match(/read `([^`]+policy\\.md)` before review/);\n" + "for (const row of [['label', '`render_proof` passed']]) { inspect(row); }\n" +) + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_native_regex_origin_does_not_borrow_a_later_string_boundary(newline, monkeypatch): + source = _JS_REGEX_BOUNDARY_SOURCE.replace("\n", newline) + with monkeypatch.context() as baseline: + baseline.setattr( + tm._JavaScriptTemplateOwnership, "quoted_string_is_complete", lambda *args: False + ) + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert before is not None + assert tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") is None + + +@pytest.mark.parametrize( + "source", + [ + "eval(/read `([^`]+policy\\.md)` before review/);", + "new Function(/read `([^`]+policy\\.md)` before review/);", + "launch(/read `([^`]+policy\\.md)` before review/);", + "const rule = /read `([^`]+policy\\.md)` before review/; eval(rule.source);", + "const rule = /read `([^`]+policy\\.md)` before review/; eval(rule['source']);", + ], +) +def test_regex_runtime_consumers_do_not_receive_native_pattern_boundaries(source): + regexes = [] + javascript_template_spans(source, lambda: None, regex_literals=regexes) + assert regexes == [] + assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="javascript") + + +def test_regex_origin_keeps_later_executable_limits_and_raw_findings(): + runtime = _JS_REGEX_BOUNDARY_SOURCE + "exec(`${tool} -rf /`);" + assert tm.has_bounded_parse_exhaustion(runtime, lambda: None, file_type="javascript") + high = _JS_REGEX_BOUNDARY_SOURCE + "exec('rm -rf /');" + assert any(f.rule_id == "TM1" for f in tm.analyze(high, "example.mjs", "javascript")) + + +def test_regex_origin_keeps_later_overbudget_executable_word(): + source = _JS_REGEX_BOUNDARY_SOURCE + "exec(`" + "a" * 5000 + "`);" + diagnostic = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert diagnostic is not None + assert diagnostic.start >= len(_JS_REGEX_BOUNDARY_SOURCE) From b51d27436789481c209f8e6dcfe912217e12d89e Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Fri, 6 Nov 2026 00:56:47 +0530 Subject: [PATCH 05/11] Keep unresolved execution outside JavaScript regex boundary proof Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 54 +++++++++++++++++-- .../test_javascript_template_ownership.py | 39 ++++++++++++-- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index f6bb577db..d9b94cdd3 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1518,13 +1518,19 @@ def javascript_template_spans( return None if quoted_strings is not None: quoted_strings.extend(_plain_quoted_value_spans(program, check_runtime)) + executable = None if regex_literals is not None: - regex_literals.extend(_plain_regex_value_spans(program, check_runtime)) + # A lexical boundary cannot replace unresolved behavior. Apply the + # existing execution barrier even when the source has no templates. + executable = _has_shell_execution_call(program, check_runtime) + if not executable: + regex_literals.extend(_plain_regex_value_spans(program, check_runtime)) templates = [node for node in program.nodes if node.type == "template_string"] if not templates: return None projected = assertion_template_shell_text(content, check_runtime) - executable = _has_shell_execution_call(program, check_runtime) + if executable is None: + executable = _has_shell_execution_call(program, check_runtime) if executable: for node in templates: check_runtime() @@ -1619,7 +1625,46 @@ def _plain_quoted_value_spans( def _plain_regex_value_spans( program: JavaScriptProgram, check_runtime: Callable[[], None] ) -> list[tuple[int, int]]: - """Keep native matching syntax separate from executable string consumers.""" + """Bound lexical regex candidates after the execution barrier. + + Matching names are candidate syntax, not a receiver type proof. Explicit + matching overrides or reflection keep the conservative shell fallback. + """ + matching = {"match", "matchAll", "search", "split", "replace", "replaceAll", "test", "exec"} + overrides = { + "String", + "RegExp", + "Reflect", + "prototype", + "constructor", + "__proto__", + "defineProperty", + "defineProperties", + "getPrototypeOf", + "setPrototypeOf", + "__defineGetter__", + "__defineSetter__", + } + for node in program.nodes: + check_runtime() + if node.type in {"identifier", "property_identifier", "string"} and ( + program.text(node).strip("\"'") in overrides + ): + return [] + if node.type in {"method_definition", "pair"}: + name = node.child_by_field_name("name") or node.child_by_field_name("key") + if name is not None and ( + name.type == "computed_property_name" or program.text(name).strip("\"'") in matching + ): + return [] + if node.type in {"assignment_expression", "augmented_assignment_expression"}: + target = node.child_by_field_name("left") + if target is not None and ( + target.type == "subscript_expression" + or target.type == "member_expression" + and _callable_member(program, target) in matching + ): + return [] for node in program.nodes: check_runtime() property_node = node.child_by_field_name("property") or node.child_by_field_name("index") @@ -1627,11 +1672,12 @@ def _plain_regex_value_spans( _callable_member(program, node) == "source" or property_node is not None and "\\" in program.text(property_node) + or node.type == "subscript_expression" + and (property_node is None or property_node.type not in {"string", "number"}) ): # Source extraction needs a separate use proof before a pattern # can stop lending boundaries to executable text. return [] - matching = {"match", "matchAll", "search", "split", "replace", "replaceAll", "test", "exec"} boundaries = { "program", "expression_statement", diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index a82b4fd0f..14d63e291 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -1055,8 +1055,39 @@ def test_regex_origin_keeps_later_executable_limits_and_raw_findings(): assert any(f.rule_id == "TM1" for f in tm.analyze(high, "example.mjs", "javascript")) -def test_regex_origin_keeps_later_overbudget_executable_word(): +def test_regex_origin_keeps_later_overbudget_executable_word(monkeypatch): source = _JS_REGEX_BOUNDARY_SOURCE + "exec(`" + "a" * 5000 + "`);" - diagnostic = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") - assert diagnostic is not None - assert diagnostic.start >= len(_JS_REGEX_BOUNDARY_SOURCE) + with monkeypatch.context() as baseline: + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert before is not None + assert after == before + + +@pytest.mark.parametrize( + "source", + [ + "import {exec} from 'node:child_process'; " + "const receiver={match(pattern){exec(String(pattern).slice(2,-1));}}; " + "receiver.match(/`$TOOL -rf \\//);", + "import cp from 'node:child_process'; cp.exec(/`$TOOL -rf \\//);", + "import {exec as launch} from 'node:child_process'; " + "const runner={exec:launch}; runner.exec(/`$TOOL -rf \\//);", + "const receiver={match(pattern){consume(pattern);}}; receiver.match(/`name/);", + "text.match = custom; text.match(/`name/);", + "text['match'] = custom; text.match(/`name/);", + "String.prototype.match = custom; text.match(/`name/);", + "RegExp.prototype.exec = custom; text.match(/`name/);", + "const rule = /`name/; use(rule['so'+'urce']);", + ], +) +def test_regex_execution_and_override_barriers_keep_existing_incompleteness(source, monkeypatch): + regexes = [] + javascript_template_spans(source, lambda: None, regex_literals=regexes) + assert regexes == [] + with monkeypatch.context() as baseline: + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert after == before From d9bf4ea9b62c35dcbb4d23e214691b80a5176d14 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Fri, 6 Nov 2026 01:01:16 +0530 Subject: [PATCH 06/11] Align regex controls with existing bounded parser contracts Signed-off-by: yashrajbasav --- .../test_javascript_template_ownership.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index 14d63e291..031690b4c 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -1019,7 +1019,7 @@ def must_not_parse(*args): @pytest.mark.parametrize("newline", ["\n", "\r\n"]) -def test_native_regex_origin_does_not_borrow_a_later_string_boundary(newline, monkeypatch): +def test_native_regex_ranges_preserve_existing_small_file_completion(newline, monkeypatch): source = _JS_REGEX_BOUNDARY_SOURCE.replace("\n", newline) with monkeypatch.context() as baseline: baseline.setattr( @@ -1027,8 +1027,13 @@ def test_native_regex_origin_does_not_borrow_a_later_string_boundary(newline, mo ) baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") - assert before is not None + assert before is None assert tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") is None + owner = tm._JavaScriptTemplateOwnership(source, lambda: None) + start = source.index("/read") + end = source.index("/);", start) + 1 + assert owner.regex_literal_end(source.index("`", start)) == end + assert owner.regex_literal_end(end) is None @pytest.mark.parametrize( @@ -1041,11 +1046,15 @@ def test_native_regex_origin_does_not_borrow_a_later_string_boundary(newline, mo "const rule = /read `([^`]+policy\\.md)` before review/; eval(rule['source']);", ], ) -def test_regex_runtime_consumers_do_not_receive_native_pattern_boundaries(source): +def test_regex_unproved_consumers_do_not_receive_native_pattern_boundaries(source, monkeypatch): regexes = [] javascript_template_spans(source, lambda: None, regex_literals=regexes) assert regexes == [] - assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="javascript") + with monkeypatch.context() as baseline: + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert after == before def test_regex_origin_keeps_later_executable_limits_and_raw_findings(): From b1561af5af03dabb276de314b1458b6da7f91667 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Fri, 6 Nov 2026 01:06:52 +0530 Subject: [PATCH 07/11] Require bounded primitive String receivers for regex ownership Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 107 ++++++++++++------ .../test_javascript_template_ownership.py | 38 ++++++- 2 files changed, 106 insertions(+), 39 deletions(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index d9b94cdd3..c67960014 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1627,10 +1627,12 @@ def _plain_regex_value_spans( ) -> list[tuple[int, int]]: """Bound lexical regex candidates after the execution barrier. - Matching names are candidate syntax, not a receiver type proof. Explicit - matching overrides or reflection keep the conservative shell fallback. + Only direct String values and immutable top-level String bindings supply + native matching receivers. Explicit overrides retain the shell fallback. """ - matching = {"match", "matchAll", "search", "split", "replace", "replaceAll", "test", "exec"} + matching = {"match", "matchAll", "search", "split", "replace", "replaceAll"} + references: dict[str, list[Node]] = {} + candidates: dict[str, tuple[Node, Node]] = {} overrides = { "String", "RegExp", @@ -1647,6 +1649,27 @@ def _plain_regex_value_spans( } for node in program.nodes: check_runtime() + if node.type in { + "identifier", + "shorthand_property_identifier", + "shorthand_property_identifier_pattern", + }: + references.setdefault(program.text(node), []).append(node) + if node.type == "variable_declarator": + binding = node.child_by_field_name("name") + value = node.child_by_field_name("value") + declaration = node.parent + if ( + binding is not None + and binding.type == "identifier" + and value is not None + and value.type == "string" + and declaration is not None + and declaration.type == "lexical_declaration" + and declaration.children[0].type == "const" + and declaration.parent == program.root_node + ): + candidates[program.text(binding)] = (binding, node) if node.type in {"identifier", "property_identifier", "string"} and ( program.text(node).strip("\"'") in overrides ): @@ -1678,13 +1701,34 @@ def _plain_regex_value_spans( # Source extraction needs a separate use proof before a pattern # can stop lending boundaries to executable text. return [] - boundaries = { - "program", - "expression_statement", - "return_statement", - "variable_declarator", - "for_in_statement", - } + string_references: set[int] = set() + budget = _MAX_TOKENS * 4 + for name, (binding, declaration) in candidates.items(): + owned = {binding.id} + for reference in references[name]: + budget -= 1 + if budget < 0: + return [] + check_runtime() + if reference == binding: + continue + member = reference.parent + call = None if member is None else member.parent + if not ( + reference.type == "identifier" + and reference.start_byte >= declaration.end_byte + and member is not None + and member.type == "member_expression" + and member.child_by_field_name("object") == reference + and _callable_member(program, member) in matching + and call is not None + and call.type == "call_expression" + and call.child_by_field_name("function") == member + ): + break + owned.add(reference.id) + else: + string_references.update(owned) spans: list[tuple[int, int]] = [] for node in program.nodes: check_runtime() @@ -1707,32 +1751,23 @@ def _plain_regex_value_spans( or program.content[pattern_end : pattern_end + 1] != "/" ): continue - parent = node.parent - while parent is not None and parent.type not in boundaries: - check_runtime() - if parent.type in { - "binary_expression", - "augmented_assignment_expression", - "new_expression", - "template_substitution", - "export_statement", - }: - break - if ( - parent.type in {"member_expression", "subscript_expression"} - and _callable_member(program, parent) not in matching - ): - break - if parent.type == "call_expression": - callee = parent.child_by_field_name("function") - if ( - callee is None - or callee.type != "member_expression" - or _callable_member(program, callee) not in matching - ): - break - parent = parent.parent - else: + arguments = node.parent + call = None if arguments is None else arguments.parent + member = None if call is None else call.child_by_field_name("function") + receiver = None if member is None else member.child_by_field_name("object") + if ( + arguments is not None + and arguments.type == "arguments" + and arguments.named_children + and arguments.named_children[0] == node + and call is not None + and call.type == "call_expression" + and member is not None + and member.type == "member_expression" + and _callable_member(program, member) in matching + and receiver is not None + and (receiver.type == "string" or receiver.id in string_references) + ): spans.append((start, end)) return spans diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index 031690b4c..ad9ca113b 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -1013,13 +1013,17 @@ def must_not_parse(*args): _JS_REGEX_BOUNDARY_SOURCE = ( + "const text = 'safe';\n" "const captured = text.match(/read `([^`]+policy\\.md)` before review/);\n" - "for (const row of [['label', '`render_proof` passed']]) { inspect(row); }\n" + + "// " + + "padding " * 1024 + + "\n" + + "for (const row of [['label', '`render_proof` passed']]) { inspect(row); }\n" ) @pytest.mark.parametrize("newline", ["\n", "\r\n"]) -def test_native_regex_ranges_preserve_existing_small_file_completion(newline, monkeypatch): +def test_native_regex_origin_does_not_borrow_a_distant_string_boundary(newline, monkeypatch): source = _JS_REGEX_BOUNDARY_SOURCE.replace("\n", newline) with monkeypatch.context() as baseline: baseline.setattr( @@ -1027,7 +1031,8 @@ def test_native_regex_ranges_preserve_existing_small_file_completion(newline, mo ) baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") - assert before is None + assert before is not None + assert before.cause is tm.ParserCause.BOUNDARY_UNRESOLVED assert tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") is None owner = tm._JavaScriptTemplateOwnership(source, lambda: None) start = source.index("/read") @@ -1100,3 +1105,30 @@ def test_regex_execution_and_override_barriers_keep_existing_incompleteness(sour before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") assert after == before + + +@pytest.mark.parametrize( + "prefix,receiver", + [ + ("import value from './runtime.mjs';", "value"), + ("const value = getValue();", "value"), + ("let value = 'safe'; value = getValue();", "value"), + ("const value = 'safe'; function shadow(value) {}", "value"), + ("function check(value) {", "value"), + ], +) +def test_unknown_or_shadowed_string_receivers_keep_the_regex_fallback(prefix, receiver): + source = prefix + receiver + ".match(/`$TOOL -rf \\//);" + if prefix.startswith("function"): + source += "}" + regexes = [] + javascript_template_spans(source, lambda: None, regex_literals=regexes) + assert regexes == [] + + +def test_direct_primitive_string_can_prove_its_matching_regex_boundaries(): + source = "'safe'.match(/`label/);" + regexes = [] + javascript_template_spans(source, lambda: None, regex_literals=regexes) + start = source.index("/`label") + assert regexes == [(start, start + len("/`label/"))] From ec9ae7536c971393205e17b415c124d52840ce1f Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Fri, 6 Nov 2026 01:09:54 +0530 Subject: [PATCH 08/11] Retain conservative regex ownership inside dynamic with scopes Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 2 ++ .../analyzers/test_javascript_template_ownership.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index c67960014..feadd6461 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1649,6 +1649,8 @@ def _plain_regex_value_spans( } for node in program.nodes: check_runtime() + if node.type == "with_statement": + return [] if node.type in { "identifier", "shorthand_property_identifier", diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index ad9ca113b..e1296c484 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -1132,3 +1132,15 @@ def test_direct_primitive_string_can_prove_its_matching_regex_boundaries(): javascript_template_spans(source, lambda: None, regex_literals=regexes) start = source.index("/`label") assert regexes == [(start, start + len("/`label/"))] + + +def test_with_scope_cannot_borrow_a_top_level_primitive_string_binding(monkeypatch): + source = "const text='safe'; with (runtimeScope) { text.match(/`$TOOL -rf \\//); }" + regexes = [] + javascript_template_spans(source, lambda: None, regex_literals=regexes) + assert regexes == [] + with monkeypatch.context() as baseline: + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert before is not None + assert tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") == before From 9ec7e25ef44eea5402a86a5e3456532d7b752352 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Fri, 6 Nov 2026 01:11:44 +0530 Subject: [PATCH 09/11] Limit JavaScript boundary refinement to proved native regex cases Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 52 ----------- .../analyzers/static_patterns_tool_misuse.py | 55 +++--------- .../test_javascript_template_ownership.py | 90 ++----------------- 3 files changed, 19 insertions(+), 178 deletions(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index feadd6461..a76abc791 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1499,7 +1499,6 @@ def javascript_template_spans( check_runtime: Callable[[], None], *, unresolved_executables: list[int] | None = None, - quoted_strings: list[tuple[int, int]] | None = None, regex_literals: list[tuple[int, int]] | None = None, ) -> JavaScriptTemplateSpans | None: """Prove host delimiters while retaining unresolved executable templates. @@ -1516,8 +1515,6 @@ def javascript_template_spans( if unresolved_executables is not None: unresolved_executables.append(content.index("`")) return None - if quoted_strings is not None: - quoted_strings.extend(_plain_quoted_value_spans(program, check_runtime)) executable = None if regex_literals is not None: # A lexical boundary cannot replace unresolved behavior. Apply the @@ -1573,55 +1570,6 @@ def javascript_template_spans( return tuple(starts), tuple(ends) -def _plain_quoted_value_spans( - program: JavaScriptProgram, check_runtime: Callable[[], None] -) -> list[tuple[int, int]]: - """Bound plain literal values; do not infer escaped or extended code.""" - spans: list[tuple[int, int]] = [] - extensions = { - "binary_expression", - "augmented_assignment_expression", - "member_expression", - "subscript_expression", - "call_expression", - "new_expression", - "template_substitution", - "import_statement", - "export_statement", - } - boundaries = { - "program", - "expression_statement", - "return_statement", - "lexical_declaration", - "variable_declarator", - "for_in_statement", - } - for node in program.nodes: - check_runtime() - if node.type != "string": - continue - start, end = program.span(node) - value = program.content[start + 1 : end - 1] - if ( - end - start < 2 - or program.content[start] not in "\"'" - or program.content[end - 1] != program.content[start] - or "\\" in value - or "$" in value - ): - continue - parent = node.parent - while parent is not None and parent.type not in boundaries: - check_runtime() - if parent.type in extensions: - break - parent = parent.parent - else: - spans.append((start, end)) - return spans - - def _plain_regex_value_spans( program: JavaScriptProgram, check_runtime: Callable[[], None] ) -> list[tuple[int, int]]: diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index e724176d4..e7886205f 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -3370,27 +3370,6 @@ def _has_shell_command_word_exhaustion( content, check_runtime ) - def candidate_is_complete(start: int) -> bool: - if _quoted_candidate_is_complete( - content, start, quote_limits, check_runtime, quoted_printf_allowed - ): - return True - if javascript_source is None or _command_string_depth >= 8: - return False - return javascript_source.quoted_string_is_complete( - start, - lambda value: ( - _has_shell_command_word_exhaustion( - value, - check_runtime, - _command_string_depth=_command_string_depth + 1, - _quoted_printf_allowed=quoted_printf_allowed, - complete_context=False, - ) - is None - ), - ) - parsed_through = 0 # A word that spans lines may pair a heredoc, comment or prose quote with # a later command line. Main skips the candidates inside such a word, so @@ -3557,7 +3536,9 @@ def candidate_is_complete(start: int) -> bool: substitution_end, check_runtime=check_runtime, ): - if not candidate_is_complete(start): + if not _quoted_candidate_is_complete( + content, start, quote_limits, check_runtime, quoted_printf_allowed + ): return ( word_diagnostics[0] if word_diagnostics @@ -3629,7 +3610,9 @@ def candidate_is_complete(start: int) -> bool: # expressions cannot continue a shell word into later code. unresolved_end = min(unresolved_end, javascript_source.word_end(start)) if unresolved_end - start > _SHELL_COMMAND_WORD_CHARS: - if not candidate_is_complete(start): + if not _quoted_candidate_is_complete( + content, start, quote_limits, check_runtime, quoted_printf_allowed + ): return ( word_diagnostics[0] if word_diagnostics @@ -3715,7 +3698,9 @@ def candidate_is_complete(start: int) -> bool: # spans and runtime-selected operands keep their existing # conservative treatment. continue - if not candidate_is_complete(start): + if not _quoted_candidate_is_complete( + content, start, quote_limits, check_runtime, quoted_printf_allowed + ): assert parsed.diagnostic is not None return parsed.diagnostic operand_diagnostic = ( @@ -3733,7 +3718,9 @@ def candidate_is_complete(start: int) -> bool: if operand_diagnostic is not None: comment = python_source.comment(start) if python_source is not None else None if comment is None: - if candidate_is_complete(start): + if _quoted_candidate_is_complete( + content, start, quote_limits, check_runtime, quoted_printf_allowed + ): continue # Host code after a Python string's closing quote is not shell # text, so a command in the string takes operands only from @@ -5817,9 +5804,6 @@ def __init__(self, content: str, check_runtime: Callable[[], None]) -> None: self._spans: JavaScriptTemplateSpans | None = None self._spans_computed = False self._unresolved_executables: list[int] = [] - self._quoted_strings: list[tuple[int, int]] = [] - self._quoted_starts: list[int] = [] - self._quoted_complete: dict[int, bool] = {} self._regex_literals: list[tuple[int, int]] = [] self._regex_starts: list[int] = [] @@ -5829,11 +5813,9 @@ def _owned_spans(self) -> JavaScriptTemplateSpans | None: self._content, self._check_runtime, unresolved_executables=self._unresolved_executables, - quoted_strings=self._quoted_strings, regex_literals=self._regex_literals, ) self._regex_starts = [start for start, _ in self._regex_literals] - self._quoted_starts = [start for start, _ in self._quoted_strings] self._spans_computed = True return self._spans @@ -5860,19 +5842,6 @@ def regex_literal_end(self, position: int) -> int | None: return end return None - def quoted_string_is_complete(self, position: int, check_value: Callable[[str], bool]) -> bool: - """Refine one failed candidate only after checking its whole value.""" - self._owned_spans() - index = bisect_right(self._quoted_starts, position) - 1 - if index < 0: - return False - start, end = self._quoted_strings[index] - if not start < position < end - 1: - return False - if start not in self._quoted_complete: - self._quoted_complete[start] = check_value(self._content[start + 1 : end - 1]) - return self._quoted_complete[start] - def owns(self, position: int) -> bool: spans = self._owned_spans() if spans is None: diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index e1296c484..b76497921 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -856,16 +856,6 @@ def test_known_native_callee_cannot_hide_security_findings() -> None: ) -def test_plain_quoted_values_expose_native_boundaries_without_becoming_templates() -> None: - source = "const values = ['`first` before', \"`second` after\"];\n" - quoted: list[tuple[int, int]] = [] - assert javascript_template_spans(source, lambda: None, quoted_strings=quoted) is None - assert [source[start:end] for start, end in quoted] == [ - "'`first` before'", - '"`second` after"', - ] - - @pytest.mark.parametrize("newline", ["\n", "\r\n"]) def test_quoted_backtick_values_cannot_consume_later_host_code(newline: str) -> None: source = newline.join( @@ -891,7 +881,7 @@ def test_completed_quoted_value_never_clears_later_runtime_uncertainty(suffix: s assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="javascript") -def test_quoted_value_refinement_preserves_raw_high_commands() -> None: +def test_regex_refinement_preserves_neighbor_raw_high_commands() -> None: source = ( "const values = ['`name` before', '`name` after'];\n" "import {execSync} from 'node:child_process'; execSync('rm -rf /');\n" @@ -901,65 +891,6 @@ def test_quoted_value_refinement_preserves_raw_high_commands() -> None: ) -@pytest.mark.parametrize( - "source", - [ - r"const value = ['`name`\n'];", - "const value = ['`$TOOL -rf /`'];", - "const value = ['`name`' + extra];", - "const value = ['`name`'.concat(extra)];", - "eval('`name`');", - "new Function('`name`');", - "unknown('`name`');", - "const value = ['`name`];", - "const value = ['unterminated];", - ], -) -def test_unproved_quoted_values_do_not_receive_boundary_refinement(source: str) -> None: - quoted: list[tuple[int, int]] = [] - javascript_template_spans(source, lambda: None, quoted_strings=quoted) - assert quoted == [] - - -def test_quoted_value_boundaries_keep_unicode_character_offsets() -> None: - source = "const label = '日本語'; const values = ['`café` before'];" - quoted: list[tuple[int, int]] = [] - javascript_template_spans(source, lambda: None, quoted_strings=quoted) - assert [source[start:end] for start, end in quoted] == ["'日本語'", "'`café` before'"] - - -def test_quoted_body_is_checked_once_and_does_not_own_its_delimiters() -> None: - source = "const values = ['`name` before'];" - owner = tm._JavaScriptTemplateOwnership(source, lambda: None) - seen: list[str] = [] - - def checked(value: str) -> bool: - seen.append(value) - return True - - start = source.index("'") - end = source.rindex("'") - assert not owner.quoted_string_is_complete(start, checked) - assert owner.quoted_string_is_complete(start + 1, checked) - assert owner.quoted_string_is_complete(start + 3, checked) - assert not owner.quoted_string_is_complete(end, checked) - assert not owner.quoted_string_is_complete(len(source) - 1, checked) - assert seen == ["`name` before"] - - -def test_interrupted_quoted_body_check_is_not_cached() -> None: - source = "const values = ['`name` before'];" - owner = tm._JavaScriptTemplateOwnership(source, lambda: None) - position = source.index("`") - - def cancelled(value: str) -> bool: - raise TimeoutError("cancelled") - - with pytest.raises(TimeoutError, match="cancelled"): - owner.quoted_string_is_complete(position, cancelled) - assert owner.quoted_string_is_complete(position, lambda value: True) - - @pytest.mark.parametrize( "source", [ @@ -970,13 +901,11 @@ def cancelled(value: str) -> bool: "const value = '`name`'; launch(`${value}${payload}`);", ], ) -def test_quoted_refinement_preserves_established_indirect_runtime_diagnostics( +def test_regex_refinement_preserves_established_indirect_runtime_diagnostics( source: str, monkeypatch ) -> None: with monkeypatch.context() as baseline: - baseline.setattr( - tm._JavaScriptTemplateOwnership, "quoted_string_is_complete", lambda *args: False - ) + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") assert before is not None @@ -988,9 +917,7 @@ def test_quoted_refinement_preserves_established_indirect_runtime_diagnostics( def test_ordinary_quoted_data_keeps_its_existing_complete_result(value, monkeypatch) -> None: source = "const values = ['`name` before', '`name` after'];\nconst other = '" + value + "';" with monkeypatch.context() as baseline: - baseline.setattr( - tm._JavaScriptTemplateOwnership, "quoted_string_is_complete", lambda *args: False - ) + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") assert before is None @@ -1004,12 +931,12 @@ def must_not_parse(*args): raise AssertionError("no-backtick source should not require a native parse") monkeypatch.setattr(javascript_tokens, "parse_javascript_source", must_not_parse) - quoted: list[tuple[int, int]] = [] + regexes: list[tuple[int, int]] = [] assert ( - javascript_template_spans("const label = 'ordinary';", lambda: None, quoted_strings=quoted) + javascript_template_spans("const label = 'ordinary';", lambda: None, regex_literals=regexes) is None ) - assert quoted == [] + assert regexes == [] _JS_REGEX_BOUNDARY_SOURCE = ( @@ -1026,9 +953,6 @@ def must_not_parse(*args): def test_native_regex_origin_does_not_borrow_a_distant_string_boundary(newline, monkeypatch): source = _JS_REGEX_BOUNDARY_SOURCE.replace("\n", newline) with monkeypatch.context() as baseline: - baseline.setattr( - tm._JavaScriptTemplateOwnership, "quoted_string_is_complete", lambda *args: False - ) baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") assert before is not None From 00b5f9d2c615ede65ca8efcfc1f01a787ec5fea7 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Fri, 6 Nov 2026 01:13:55 +0530 Subject: [PATCH 10/11] Preserve the JavaScript no-regex execution fast path Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 2 +- .../test_javascript_template_ownership.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index a76abc791..d82d95ce7 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1516,7 +1516,7 @@ def javascript_template_spans( unresolved_executables.append(content.index("`")) return None executable = None - if regex_literals is not None: + if regex_literals is not None and any(node.type == "regex" for node in program.nodes): # A lexical boundary cannot replace unresolved behavior. Apply the # existing execution barrier even when the source has no templates. executable = _has_shell_execution_call(program, check_runtime) diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index b76497921..9828ba2d0 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -1068,3 +1068,18 @@ def test_with_scope_cannot_borrow_a_top_level_primitive_string_binding(monkeypat before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") assert before is not None assert tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") == before + + +def test_quoted_backtick_data_keeps_the_no_regex_execution_fast_path(monkeypatch): + from skillspector import javascript_tokens + + def must_not_analyze_execution(*args): + raise AssertionError("ordinary quoted data needs no regex execution proof") + + monkeypatch.setattr(javascript_tokens, "_has_shell_execution_call", must_not_analyze_execution) + regexes = [] + assert ( + javascript_template_spans("const label='`name`';", lambda: None, regex_literals=regexes) + is None + ) + assert regexes == [] From 9c63f32a40282db631869958b952768c318a0a8a Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Fri, 6 Nov 2026 01:39:01 +0530 Subject: [PATCH 11/11] Keep proven matching patterns outside shell command reconstruction Signed-off-by: yashrajbasav --- src/skillspector/javascript_tokens.py | 7 ++- .../analyzers/static_patterns_tool_misuse.py | 13 ++--- .../test_javascript_template_ownership.py | 50 +++++++++++++++++-- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index d82d95ce7..39f0bce34 100644 --- a/src/skillspector/javascript_tokens.py +++ b/src/skillspector/javascript_tokens.py @@ -1705,11 +1705,14 @@ def _plain_regex_value_spans( call = None if arguments is None else arguments.parent member = None if call is None else call.child_by_field_name("function") receiver = None if member is None else member.child_by_field_name("object") + previous = node.prev_named_sibling + while previous is not None and previous.type == "comment": + check_runtime() + previous = previous.prev_named_sibling if ( arguments is not None and arguments.type == "arguments" - and arguments.named_children - and arguments.named_children[0] == node + and previous is None and call is not None and call.type == "call_expression" and member is not None diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index e7886205f..707dbdd34 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -3414,7 +3414,11 @@ def _has_shell_command_word_exhaustion( continue if start in owned_word_positions: continue - if javascript_source is not None and javascript_source.owns(start): + if javascript_source is not None and ( + javascript_source.owns(start) or javascript_source.regex_literal_end(start) is not None + ): + # Proven native matching patterns have no shell-word semantics. + # This candidate owns no later host code or raw finding input. continue if powershell is not None and powershell.owns(start): continue @@ -3507,13 +3511,6 @@ def _has_shell_command_word_exhaustion( quoted_printf_allowed=quoted_printf_allowed, diagnostics=word_diagnostics, ) - regex_end = ( - javascript_source.regex_literal_end(start) if javascript_source is not None else None - ) - if regex_end is not None and (parsed is None or parsed.limited or parsed.end > regex_end): - # RegExp backticks belong to matching syntax; a failed shell read - # cannot acquire ownership of later JavaScript through them. - continue if parsed is None: substitution_start = ( start + 1 diff --git a/tests/nodes/analyzers/test_javascript_template_ownership.py b/tests/nodes/analyzers/test_javascript_template_ownership.py index 9828ba2d0..5609082da 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -993,14 +993,16 @@ def test_regex_origin_keeps_later_executable_limits_and_raw_findings(): assert any(f.rule_id == "TM1" for f in tm.analyze(high, "example.mjs", "javascript")) -def test_regex_origin_keeps_later_overbudget_executable_word(monkeypatch): - source = _JS_REGEX_BOUNDARY_SOURCE + "exec(`" + "a" * 5000 + "`);" +def test_regex_origin_keeps_later_overbudget_host_word(monkeypatch): + source = _JS_REGEX_BOUNDARY_SOURCE + "r" * (tm._SHELL_COMMAND_WORD_CHARS + 1) + ";\n" with monkeypatch.context() as baseline: baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") after = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") assert before is not None - assert after == before + assert after is not None + assert after.cause is tm.ParserCause.RESOURCE_LIMIT + assert after.start >= len(_JS_REGEX_BOUNDARY_SOURCE) @pytest.mark.parametrize( @@ -1083,3 +1085,45 @@ def must_not_analyze_execution(*args): is None ) assert regexes == [] + + +def test_native_matching_pattern_dollars_are_data_not_runtime_commands(monkeypatch): + source = "'safe'.match(/`$TOOL -rf \\//);" + with monkeypatch.context() as baseline: + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert before is not None + assert before.cause is tm.ParserCause.RUNTIME_DEPENDENT + assert tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") is None + + +@pytest.mark.parametrize("comment", ["/* pattern */ ", "// pattern\n", "/* first */ /* second */ "]) +def test_first_regex_argument_can_follow_native_comments(comment, monkeypatch): + source = _JS_REGEX_BOUNDARY_SOURCE.replace("text.match(", "text.match(" + comment) + with monkeypatch.context() as baseline: + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert before is not None + assert tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") is None + + +def test_comments_do_not_make_a_second_argument_a_matching_pattern(monkeypatch): + source = _JS_REGEX_BOUNDARY_SOURCE.replace( + "text.match(", "text.match(unresolved, /* pattern */ " + ) + with monkeypatch.context() as baseline: + baseline.setattr(tm._JavaScriptTemplateOwnership, "regex_literal_end", lambda *args: None) + before = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert before is not None + assert tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") == before + + +def test_native_pattern_boundaries_cannot_hide_a_later_dynamic_consumer(): + source = "'safe'.match(/`$TOOL -rf \\//);\nexec(`${tool} -rf /`);" + diagnostic = tm.bounded_parse_diagnostic(source, lambda: None, file_type="javascript") + assert diagnostic is not None + assert diagnostic.cause is tm.ParserCause.RUNTIME_DEPENDENT + assert any( + f.rule_id == "TM1" + for f in tm.analyze(source + "exec('rm -rf /');", "control.mjs", "javascript") + )