diff --git a/src/skillspector/javascript_tokens.py b/src/skillspector/javascript_tokens.py index 7a58bbd73..2d82579f6 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, + regex_literals: list[tuple[int, int]] | None = None, ) -> JavaScriptTemplateSpans | None: """Prove host delimiters while retaining unresolved executable templates. @@ -1514,11 +1515,19 @@ def javascript_template_spans( if unresolved_executables is not None: unresolved_executables.append(content.index("`")) return None + executable = 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) + 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() @@ -1561,6 +1570,161 @@ def javascript_template_spans( return tuple(starts), tuple(ends) +def _plain_regex_value_spans( + program: JavaScriptProgram, check_runtime: Callable[[], None] +) -> list[tuple[int, int]]: + """Bound lexical regex candidates after the execution barrier. + + 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"} + references: dict[str, list[Node]] = {} + candidates: dict[str, tuple[Node, Node]] = {} + overrides = { + "String", + "RegExp", + "Reflect", + "prototype", + "constructor", + "__proto__", + "defineProperty", + "defineProperties", + "getPrototypeOf", + "setPrototypeOf", + "__defineGetter__", + "__defineSetter__", + } + for node in program.nodes: + check_runtime() + if node.type == "with_statement": + return [] + 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 + ): + 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") + 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) + 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 [] + 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() + 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 + 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") + 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 previous is None + 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 + + 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 fbff5b25f..cdec26967 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -3403,6 +3403,7 @@ def _has_shell_command_word_exhaustion( quoted_printf_allowed = _quoted_printf_allowed and not _shell_redefines_command( content, check_runtime ) + 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 @@ -3447,7 +3448,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 @@ -6134,6 +6139,8 @@ 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._regex_literals: list[tuple[int, int]] = [] + self._regex_starts: list[int] = [] def _owned_spans(self) -> JavaScriptTemplateSpans | None: if not self._spans_computed: @@ -6141,7 +6148,9 @@ def _owned_spans(self) -> JavaScriptTemplateSpans | None: self._content, self._check_runtime, unresolved_executables=self._unresolved_executables, + regex_literals=self._regex_literals, ) + self._regex_starts = [start for start, _ in self._regex_literals] self._spans_computed = True return self._spans @@ -6158,6 +6167,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 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 652cbf397..9f091b96b 100644 --- a/tests/nodes/analyzers/test_javascript_template_ownership.py +++ b/tests/nodes/analyzers/test_javascript_template_ownership.py @@ -979,3 +979,276 @@ 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") ) + + +@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}`);", + ], +) +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_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" + ) + assert any( + finding.rule_id == "TM1" for finding in tm.analyze(source, "control.mjs", "javascript") + ) + + +@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_regex_refinement_preserves_established_indirect_runtime_diagnostics( + source: str, monkeypatch +) -> None: + 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 before.cause is tm.ParserCause.RUNTIME_DEPENDENT + assert after == before + + +@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, "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 + assert after is None + + +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) + regexes: list[tuple[int, int]] = [] + assert ( + javascript_template_spans("const label = 'ordinary';", lambda: None, regex_literals=regexes) + is None + ) + assert regexes == [] + + +_JS_REGEX_BOUNDARY_SOURCE = ( + "const text = 'safe';\n" + "const captured = text.match(/read `([^`]+policy\\.md)` before review/);\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_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, "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.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") + 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( + "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_unproved_consumers_do_not_receive_native_pattern_boundaries(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 + + +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_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 is not None + assert after.cause is tm.ParserCause.RESOURCE_LIMIT + assert after.start >= len(_JS_REGEX_BOUNDARY_SOURCE) + + +@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 + + +@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/"))] + + +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 + + +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 == [] + + +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") + )