diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index 66cee7e10..6278d139d 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -60,6 +60,15 @@ _PRINTF_STATIC_CHARS = 256 _PRINTF_STATIC_ARGUMENTS = 32 _PRINTF_STATIC_WORD_RE = re.compile(r"[-A-Za-z0-9_./*?%]{0,64}") +# A bounded ``printf`` format operand may also carry POSIX backslash escapes. +_PRINTF_FORMAT_WORD_RE = re.compile(r"[-A-Za-z0-9_./*?%\\]{0,64}") +# The old deterministic alphabet plus the control/high-byte values needed by +# byte-oriented probes. Command substitutions are expanded after shell syntax +# has been recognized, so decoded angle brackets remain literal word data +# rather than redirections. Anything else outside this set is either a shell +# separator, a metacharacter the downstream glob model does not handle, or +# otherwise not provably inert, so the substitution stays undecidable. +_PRINTF_SAFE_WORD_RE = re.compile(r"[\x01-\x08\x0e-\x1f\x7f-\xffA-Za-z0-9_./*?%<>-]*") _DESTRUCTIVE_COMMAND_BASENAMES = frozenset({"rm", "del", "erase"}) _QUOTED_GLOB_SENTINEL = "\ue000" _DYNAMIC_SHELL_WORD_SENTINEL = "\ue001" @@ -1202,6 +1211,77 @@ def _next_shell_invocation_word( return "".join(output) if word_started else None, cursor, False +_PRINTF_HEX_ESCAPE_RE = re.compile(r"[0-9A-Fa-f]{1,2}") +_PRINTF_OCTAL_ESCAPE_RE = re.compile(r"[0-7]{1,3}") +_PRINTF_ESCAPE_SEQUENCES = { + "a": "\a", + "b": "\b", + "e": "\x1b", + "E": "\x1b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + "v": "\v", + "\\": "\\", + "'": "'", + '"': '"', +} + + +def _decode_printf_escapes(text: str) -> str | None: + """Decode bounded escapes as modeled by Bash builtin/coreutils ``printf``. + + This intentionally models the Bash/GNU-coreutils escape subset used by the + static parser. Extensions such as ``\\xHH``, ``\\e``/``\\E``, ``\\'`` and + ``\\"`` are not portable to a dash ``printf`` and are not a general POSIX + guarantee. Unknown or trailing escapes make the substitution undecidable + and return ``None`` so the caller keeps its fail-closed behaviour. + """ + output: list[str] = [] + cursor = 0 + while cursor < len(text): + character = text[cursor] + if character != "\\": + output.append(character) + cursor += 1 + continue + if cursor + 1 >= len(text): + return None + escaped = text[cursor + 1] + simple = _PRINTF_ESCAPE_SEQUENCES.get(escaped) + if simple is not None: + output.append(simple) + cursor += 2 + continue + if escaped == "x": + digits = _PRINTF_HEX_ESCAPE_RE.match(text, cursor + 2) + if digits is None: + return None + output.append(chr(int(digits.group(0), 16))) + cursor = digits.end() + continue + if escaped in "01234567": + digits = _PRINTF_OCTAL_ESCAPE_RE.match(text, cursor + 1) + if digits is None: + return None + value = int(digits.group(0), 8) + if value > 0o377: + return None + output.append(chr(value)) + cursor = digits.end() + continue + return None + return "".join(output) + + +def _bounded_printf_word(result: str) -> str | None: + """Return a decoded ``printf`` result only when it stays one inert word.""" + if _PRINTF_SAFE_WORD_RE.fullmatch(result) is None: + return None + return result + + def _static_printf_substitution( content: str, start: int, @@ -1225,13 +1305,29 @@ def _static_printf_substitution( if not arguments or len(arguments) > _PRINTF_STATIC_ARGUMENTS + 1: return None format_word, *values = arguments - if _PRINTF_STATIC_WORD_RE.fullmatch(format_word) is None or any( + if _PRINTF_FORMAT_WORD_RE.fullmatch(format_word) is None or any( _PRINTF_STATIC_WORD_RE.fullmatch(value) is None for value in values ): return None segments: list[str | None] = [] literal: list[str] = [] + + def flush_literal() -> bool: + # POSIX ``printf`` decodes escapes in the format operand before any + # conversion runs, so decode each literal segment in place. Decoding + # after the conversion split keeps an escaped percent sign from + # re-entering conversion parsing. + text = "".join(literal) + literal.clear() + if "\\" in text: + decoded = _decode_printf_escapes(text) + if decoded is None: + return False + text = decoded + segments.append(text) + return True + cursor = 0 while cursor < len(format_word): character = format_word[cursor] @@ -1241,18 +1337,17 @@ def _static_printf_substitution( continue if cursor + 1 >= len(format_word) or format_word[cursor + 1] not in "s%": return None - if literal: - segments.append("".join(literal)) - literal.clear() + if literal and not flush_literal(): + return None conversion = format_word[cursor + 1] segments.append(None if conversion == "s" else "%") cursor += 2 - if literal: - segments.append("".join(literal)) + if literal and not flush_literal(): + return None conversions = sum(segment is None for segment in segments) if conversions == 0: - return format_word + return _bounded_printf_word("".join(segments)) remaining = iter(values) output: list[str] = [] cycles = max(1, (len(values) + conversions - 1) // conversions) @@ -1261,8 +1356,7 @@ def _static_printf_substitution( output.append(next(remaining, "") if segment is None else segment) if sum(len(piece) for piece in output) > 64: return None - result = "".join(output) - return result if _PRINTF_STATIC_WORD_RE.fullmatch(result) is not None else None + return _bounded_printf_word("".join(output)) def _may_have_destructive_outer_operands(content: str) -> bool: diff --git a/tests/nodes/analyzers/test_static_parse_limit_literals.py b/tests/nodes/analyzers/test_static_parse_limit_literals.py new file mode 100644 index 000000000..72cac972a --- /dev/null +++ b/tests/nodes/analyzers/test_static_parse_limit_literals.py @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A bounded, literal ``printf`` substitution must not look partially inspected. + +Issue #694: the ELF-probe idiom used by ``rustup-init.sh`` made +``static_patterns_tool_misuse`` report ``static_parse_limit``, so the file could +never reach ``safe_to_install``. The substitution is bounded and its output is +fully determined, so the deterministic evaluator has to resolve it instead of +falling back to the span-bound failure path. +""" + +from __future__ import annotations + +import pytest + +from skillspector.inspection_ledger import LedgerOutcome, LedgerReason +from skillspector.nodes.analyzers import static_patterns_tool_misuse as tm +from skillspector.nodes.analyzers import static_runner + +# Verbatim POSIX probe from the issue report (rustup-init.sh line 246). +_RUSTUP_ELF_PROBE = ( + "#!/bin/sh\n" + '_current_exe_head=$(head -c 5 "$1")\n' + 'if [ "$_current_exe_head" = "$(printf \'\\177ELF\\001\')" ]; then\n' + " echo elf\n" + "fi\n" +) + +# Verbatim shape of the machine-byte probe from rustup-init.sh line 266. +_RUSTUP_MACHINE_PROBE = ( + '#!/bin/sh\n_m=$(head -c 19 "$1" | tail -c 1)\n[ "$_m" = "$(printf \'\\076\')" ]\n' +) + +_LITERAL_SUBSTITUTIONS = [ + pytest.param("$(printf '\\177ELF\\001')", "\x7fELF\x01", id="elf-probe-octal"), + pytest.param("$(printf '\\074')", "<", id="octal-less-than"), + pytest.param("$(printf '\\076')", ">", id="octal-greater-than"), + pytest.param("$(printf '\\x41\\x42')", "AB", id="hex-escapes"), + pytest.param("$(printf '\\162\\155')", "rm", id="octal-letters"), + pytest.param("$(printf 'abc')", "abc", id="plain-literal"), + pytest.param("$(printf '%%s')", "%s", id="escaped-percent"), +] + + +@pytest.mark.parametrize("substitution,expected", _LITERAL_SUBSTITUTIONS) +def test_bounded_printf_is_resolved_statically(substitution: str, expected: str) -> None: + assert tm._static_printf_substitution(substitution, 0, len(substitution)) == expected + + +@pytest.mark.parametrize("substitution,_expected", _LITERAL_SUBSTITUTIONS) +def test_bounded_printf_substitution_completes_static_analysis( + substitution: str, _expected: str +) -> None: + source = f'echo "{substitution}"\n' + assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="shell") is False + + +def test_issue_reproducer_completes_static_analysis() -> None: + assert ( + tm.has_bounded_parse_exhaustion(_RUSTUP_ELF_PROBE, lambda: None, file_type="shell") is False + ) + + +@pytest.mark.parametrize("escape", ["076", "077", "001"]) +def test_rustup_machine_probe_completes_static_analysis(escape: str) -> None: + source = _RUSTUP_MACHINE_PROBE.replace("\\076", f"\\{escape}", 1) + assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="shell") is False + + +def test_issue_reproducer_reaches_completed_ledger_outcome() -> None: + path = "install.sh" + result = static_runner.run_static_patterns_with_ledger( + {"components": [path], "file_cache": {path: _RUSTUP_ELF_PROBE}}, [tm] + ) + row = result["inspection_ledger"][0] + assert row["path"] == path + assert row["analyzer_id"] == "static_patterns_tool_misuse" + assert row["outcome"] is LedgerOutcome.COMPLETED + assert "reason_code" not in row + + +def test_rustup_machine_probe_reaches_completed_ledger_outcome() -> None: + path = "install.sh" + result = static_runner.run_static_patterns_with_ledger( + {"components": [path], "file_cache": {path: _RUSTUP_MACHINE_PROBE}}, [tm] + ) + row = result["inspection_ledger"][0] + assert row["path"] == path + assert row["analyzer_id"] == "static_patterns_tool_misuse" + assert row["outcome"] is LedgerOutcome.COMPLETED + assert "reason_code" not in row + + +@pytest.mark.parametrize( + "substitution", + [ + pytest.param("$(printf 'rm -rf /')", id="literal-destructive"), + pytest.param("$(printf '%s%s' 'rm -rf' ' /')", id="assembled-destructive"), + pytest.param("$(printf '\\162\\155 -rf /')", id="octal-prefixed-destructive"), + pytest.param("$(printf '\\040rm')", id="escape-emits-separator"), + pytest.param("$(printf '\\047rm')", id="escape-emits-quote"), + pytest.param("$(printf '\\400')", id="octal-overflow-nul"), + pytest.param("$(printf '\\440')", id="octal-overflow-space"), + pytest.param("$(printf '\\447')", id="octal-truncates-to-quote"), + pytest.param("$(printf '\\504')", id="octal-truncates-to-dollar"), + pytest.param("$(printf '\\133\\135')", id="decoded-brackets"), + pytest.param("$(printf '\\qrm')", id="unknown-escape"), + ], +) +def test_destructive_or_undecidable_printf_stays_partial(substitution: str) -> None: + source = f'echo "{substitution}"\n' + assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="shell") is True + + +@pytest.mark.parametrize( + "substitution", + [ + "$(printf '\\400')", + "$(printf '\\440')", + "$(printf '\\447')", + "$(printf '\\504')", + ], +) +def test_out_of_range_octal_is_not_resolved(substitution: str) -> None: + """Octals above one byte stay fail-closed instead of producing a code point.""" + assert tm._static_printf_substitution(substitution, 0, len(substitution)) is None + + +def test_decoded_brackets_are_not_treated_as_inert() -> None: + """Unmodeled glob metacharacters must keep the substitution undecidable.""" + substitution = "$(printf '\\133\\135')" + assert tm._static_printf_substitution(substitution, 0, len(substitution)) is None + + +def test_destructive_printf_reaches_partial_ledger_outcome() -> None: + path = "install.sh" + source = "echo \"$(printf 'rm -rf /')\"\n" + result = static_runner.run_static_patterns_with_ledger( + {"components": [path], "file_cache": {path: source}}, [tm] + ) + row = result["inspection_ledger"][0] + assert row["outcome"] is LedgerOutcome.PARTIAL + assert row["reason_code"] is LedgerReason.STATIC_PARSE_LIMIT + + +@pytest.mark.parametrize( + "source", + [ + pytest.param("$CMD -rf /\n", id="runtime-selected-command"), + pytest.param("$CMD -rf {/,{1..2}}\n", id="unsupported-brace-expansion"), + pytest.param('name="value\n' + "text " * 1200, id="unclosed-assignment-quote"), + ], +) +def test_span_bound_failures_are_unchanged(source: str) -> None: + assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="shell") is True