From 011c792a0442b147ce39727928d05c37a95f7a49 Mon Sep 17 00:00:00 2001 From: elliottwaves-20 Date: Fri, 9 Oct 2026 20:37:54 +0200 Subject: [PATCH] fix(analyzer): keep checking command lines inside a word that spans lines A quote in a heredoc body, a shell comment or Markdown prose can pair with a quote on a later command line. The command-word scan then parsed one word across those lines and skipped every candidate inside it, so a runtime command such as cat < Signed-off-by: elliottwaves-20 --- .../analyzers/static_patterns_tool_misuse.py | 40 +++++++-- .../analyzers/test_multiline_word_shadow.py | 87 +++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 tests/nodes/analyzers/test_multiline_word_shadow.py diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index 0d687a77b..9449c3c90 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -514,6 +514,14 @@ def _is_shell_command_word_start(content: str, start: int) -> bool: return False +def _is_shell_command_position(content: str, start: int) -> bool: + """Return whether ``start`` opens a line or follows a command separator.""" + cursor = start - 1 + while cursor >= 0 and content[cursor] in " \t": + cursor -= 1 + return cursor < 0 or content[cursor] in "\n\r;|&(" + + def _has_quoted_assignment_prefix(content: str, start: int) -> bool: """Return whether a quoted candidate starts with a shell assignment name.""" if start >= len(content) or content[start] not in "'\"": @@ -1980,6 +1988,11 @@ def _has_shell_command_word_exhaustion( which reparses no string. """ 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 + # they are still checked here, but they never change what later + # candidates are skipped or owned. + shadow_through = 0 # Completed words own closing quotes, never executable expansion starts. # Inner commands remain independent candidates; never suppress their bodies. owned_word_positions: set[int] = set() @@ -2008,6 +2021,9 @@ def _has_shell_command_word_exhaustion( continue if _has_quoted_assignment_prefix(content, start): continue + shadowed = start < shadow_through + if shadowed and not _is_shell_command_position(content, start): + continue candidate_word_positions: set[int] = set() parsed = _parse_shell_command_word( content, @@ -2044,6 +2060,9 @@ def _has_shell_command_word_exhaustion( check_runtime=check_runtime, ): return True + if shadowed: + # The spanning word already closed this region's quotes. + continue # An unclosed expansion or quote can consume the rest of the # artifact. Once that unresolved span exceeds the command-word # budget, treating it as clean would turn malformed, deeply nested @@ -2068,7 +2087,7 @@ def _has_shell_command_word_exhaustion( or _SHELL_COMMENT_START_RE.search(content, content.rfind("\n", 0, start) + 1, start) is not None ) - if not confined_word: + if not confined_word and not shadowed: # A quote that opens a runtime-selected word stays an independent # candidate, so a mis-paired claim cannot hide its operands. owned_word_positions.update( @@ -2088,12 +2107,19 @@ def _has_shell_command_word_exhaustion( is not None ) if ( - not parsed.dynamic - or "$" not in raw_word - or not any(marker in raw_word for marker in ("$(", "`")) - or simple_backtick_parameter - ) and not (assignment_quote and confined_word): - parsed_through = max(parsed_through, parsed.end) + ( + not parsed.dynamic + or "$" not in raw_word + or not any(marker in raw_word for marker in ("$(", "`")) + or simple_backtick_parameter + ) + and not (assignment_quote and confined_word) + and not shadowed + ): + if python_source is None and ("\n" in raw_word or "\r" in raw_word): + shadow_through = max(shadow_through, parsed.end) + else: + parsed_through = max(parsed_through, parsed.end) if assignment_quote: # An assignment value never names the command, and main never # parsed this position, so it only claims ownership. diff --git a/tests/nodes/analyzers/test_multiline_word_shadow.py b/tests/nodes/analyzers/test_multiline_word_shadow.py new file mode 100644 index 000000000..f7b7ed57a --- /dev/null +++ b/tests/nodes/analyzers/test_multiline_word_shadow.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A word that spans lines never hides a later command line (#694).""" + +from __future__ import annotations + +import pytest + +from skillspector.inspection_ledger import LedgerOutcome +from skillspector.nodes.analyzers import static_patterns_tool_misuse as tm_module +from skillspector.nodes.analyzers import static_runner + +_PADDING = "\n# ordinary padding\n" * 400 +_TAIL = '\nE\n$CMD"" -rf / # "\n' + + +def _exhausted(content: str, file_type: str = "shell") -> bool: + return tm_module.has_bounded_parse_exhaustion( + content, lambda: None, file_type=file_type, complete_context=True + ) + + +@pytest.mark.parametrize( + ("content", "file_type"), + [ + ('# x "\n$CMD"" -rf / # "\n', "shell"), + ('cat < None: + # A quote in a heredoc body, a comment or prose may pair with a quote on + # a later command line. The word between them must not hide that line. + assert _exhausted(content, file_type) + assert _exhausted(content + _PADDING, file_type) + + +def test_heredoc_quote_cannot_hide_a_runtime_rm_at_scan_level() -> None: + script = ( + '#!/bin/sh\nCMD="${TOOL:-rm}"\ncat < None: + content = 'echo "line1\nline2 $HOME"\nls -la\n' + assert not _exhausted(content) + assert not _exhausted(content + _PADDING) + + +def test_dynamic_multiline_word_matches_main() -> None: + # Main never advanced past a dynamic ``$(`` word, so its inner commands + # were already independent candidates. + content = 'echo "$(date)\nnext"\nls\n' + assert not _exhausted(content) + assert not _exhausted(content + _PADDING) + + +def test_python_source_keeps_main_frontier() -> None: + content = 'x = """he said "hi\nmore"""\nprint(x)\n' + assert not _exhausted(content, "python") + assert not _exhausted(content + _PADDING, "python")