diff --git a/src/skillspector/nodes/analyzers/static_runner.py b/src/skillspector/nodes/analyzers/static_runner.py index f4aab62bf..fbc2cee3f 100644 --- a/src/skillspector/nodes/analyzers/static_runner.py +++ b/src/skillspector/nodes/analyzers/static_runner.py @@ -2227,6 +2227,7 @@ def _scan_declared_marker_views( prepared_analyses: Mapping[int, object] | None = None, parser_diagnostics: list[StaticParserDiagnostic] | None = None, python_import_bounds: PythonImportBounds | None = None, + parser_file_type: str | None = None, parser_analyses: Mapping[int, object] | None = None, ) -> tuple[list[Finding], bool, bool, _StaticResourceLimitError | None]: """Reconstruct marker payloads with directive-relative context windows.""" @@ -2243,6 +2244,7 @@ def check_runtime() -> None: ) from exc check_runtime() + bounded_file_type = parser_file_type or _infer_file_type(path) projection_limited = False bounded_parse_limited = False seen_views: set[tuple[str, int, int]] = set() @@ -2281,7 +2283,7 @@ def defer_output_limit( # The proof covers the whole artifact, so it holds in every window. It is # computed only when a loose quoted directive header reaches a quote. python_strings = ( - PythonStringClosers(content, check_runtime) if _infer_file_type(path) == "python" else None + PythonStringClosers(content, check_runtime) if bounded_file_type == "python" else None ) for owned_start, raw_start in zip(owned_starts, raw_starts, strict=True): @@ -2440,7 +2442,7 @@ def defer_output_limit( module, marker_view, check_runtime, - file_type=_infer_file_type(path), + file_type=bounded_file_type, complete_context=complete_context, source_start=raw_start, diagnostics=parser_diagnostics, @@ -2620,6 +2622,9 @@ def _scan_all_views_with_parser_diagnostics( if isinstance(diagnostic, StaticParserDiagnostic): parser_diagnostics.append(diagnostic.enclosed(0, len(content))) return findings, reason, metrics + # Decode ownership uses JSON grammar; rule applicability retains the carrier + # type so opaque and unknown bundle fields keep their original security checks. + parser_file_type = "json" if _oms_projected else _infer_file_type(path) ast_modules = [module for module in pattern_modules if _uses_python_ast(module)] lexical_modules = [module for module in pattern_modules if not _uses_python_ast(module)] if python_source is None: @@ -2809,6 +2814,7 @@ def limited_result( prepared_analyses=prepared_analyses, parser_diagnostics=parser_diagnostics, python_import_bounds=python_import_bounds, + parser_file_type="json" if _oms_projected else None, parser_analyses=parser_analyses, ) bounded_parse_limited = bounded_parse_limited or marker_bounded_parse_limited @@ -2850,7 +2856,7 @@ def limited_result( module, SecurityTextView("raw", content), finding_budget.check_runtime, - file_type=_infer_file_type(path), + file_type=parser_file_type, complete_context=True, diagnostics=parser_diagnostics, path=path, @@ -2973,10 +2979,12 @@ def limited_result( ) if callable(exhaustion_hook) and full_view.name != "raw": finding_budget.check_runtime() - module_file_type = _effective_module_file_type( - path, - module, - python_source=python_source, + module_file_type = ( + parser_file_type + if _oms_projected + else _effective_module_file_type( + path, module, python_source=python_source + ) ) bounded_parse_limited = bounded_parse_limited or bool( _bounded_parser_incomplete( diff --git a/tests/nodes/analyzers/test_oms_projected_file_type.py b/tests/nodes/analyzers/test_oms_projected_file_type.py new file mode 100644 index 000000000..8eefe1bf4 --- /dev/null +++ b/tests/nodes/analyzers/test_oms_projected_file_type.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Decoded bundle syntax is independent of the carrier filename.""" + +import json + +import pytest + +from skillspector.artifacts import SecurityTextView +from skillspector.nodes.analyzers import static_patterns_tool_misuse as tm +from skillspector.nodes.analyzers import static_runner +from skillspector.oms import OMSProjection + + +def _scan(monkeypatch, path, projected, *, complete=True): + carrier = '{"dsseEnvelope": "synthetic carrier"}' + monkeypatch.setattr( + static_runner, + "project_oms_content", + lambda _content: OMSProjection(SecurityTextView("oms-decoded", projected), complete), + ) + return static_runner._scan_all_views_detailed(path, carrier, [tm], None) + + +@pytest.mark.parametrize("path", ["skill.oms.sig", "bundle.json", "notes.md", "bundle.py"]) +def test_projected_json_owns_quotes_regardless_of_carrier_name(monkeypatch, path): + projected = json.dumps({"readable": ["label`with'apostrophe", "ordinary reference"]}) + findings, reason, metrics = _scan(monkeypatch, path, projected) + assert findings == [] + assert reason is None + assert getattr(metrics, "parser_diagnostic", None) is None + + +@pytest.mark.parametrize("complete", [False, True]) +@pytest.mark.parametrize("path", ["skill.oms.sig", "bundle.json", "bundle.py"]) +def test_projected_commands_keep_findings_and_original_carrier(monkeypatch, path, complete): + projected = json.dumps({"instruction": "rm -rf /"}) + findings, _, _ = _scan(monkeypatch, path, projected, complete=complete) + destructive = [finding for finding in findings if finding.rule_id == "TM1"] + assert destructive + assert all( + finding.file == path and finding.start_line == 1 and finding.evidence.get("oms_projection") + for finding in destructive + ) + + +def test_unrecognized_raw_bundle_keeps_runtime_diagnostic(monkeypatch): + monkeypatch.setattr( + static_runner, + "project_oms_content", + lambda content: OMSProjection(SecurityTextView("raw", content), False), + ) + _, _, metrics = static_runner._scan_all_views_detailed( + "skill.oms.sig", 'sh -c "$CMD -rf /"', [tm], None + ) + assert metrics.parser_diagnostic is not None