diff --git a/src/skillspector/nested_artifacts.py b/src/skillspector/nested_artifacts.py index 811586f46..1a48c257c 100644 --- a/src/skillspector/nested_artifacts.py +++ b/src/skillspector/nested_artifacts.py @@ -12,6 +12,7 @@ from __future__ import annotations import io +import re import stat import struct import time @@ -389,6 +390,37 @@ def _zip_member_is_link(info: zipfile.ZipInfo) -> bool: b"\xbf\xba\xfe\xca", ) +_TYPESCRIPT_DECLARATION_SUFFIXES = (".d.ts", ".d.cts", ".d.mts") +_TYPESCRIPT_DECLARATION_FILE = re.compile( + r"\A\s*(?:" + r"(?:declare\s+(?:const|let|var|function|class|namespace|module)\b[^;{}]*;)|" + r"(?:(?:export\s+)?(?:interface|namespace)\b[^{}]*\{[^{}]*\}\s*;?)|" + r"(?:(?:export\s+)?type\b[^;{}]+;)|" + r"(?:import\s+type\b[^;{}]+;)|" + r"(?:export\s*\{[^{}]*\}\s*;?)" + r")+(?:\s|/\*.*?\*/|//[^\r\n]*)*\Z", + re.DOTALL, +) + + +def _looks_like_typescript_declaration(path: str, data: bytes) -> bool: + """Recognize clearly inert TypeScript declaration content conservatively. + + Declaration suffixes alone are not trusted: a file named ``evil.d.cts`` + can still contain executable CommonJS. Unknown or non-text content stays + executable so this check cannot create a name-based security bypass. + """ + name = Path(path).name.lower() + if not name.endswith(_TYPESCRIPT_DECLARATION_SUFFIXES) or not data: + return False + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + return False + text = re.sub(r"/\*.*?\*/|//[^\r\n]*", "", text, flags=re.DOTALL) + text = re.sub(r"\s+", " ", text).strip() + return bool(text.strip() and _TYPESCRIPT_DECLARATION_FILE.fullmatch(text)) + def has_binary_executable_magic(data: bytes) -> bool: """Return whether canonical bytes begin with supported executable magic.""" @@ -399,7 +431,12 @@ def is_executable_content(path: str, data: bytes, mode: int = 0) -> bool: """Classify filesystem and archive content with one static-only policy.""" suffix = Path(path).suffix.lower() executable_magic = data.startswith(b"#!") or has_binary_executable_magic(data) - return suffix in _EXECUTABLE_SUFFIXES or executable_magic or bool(mode & 0o111) + declaration_only = _looks_like_typescript_declaration(path, data) + return ( + (suffix in _EXECUTABLE_SUFFIXES and not declaration_only) + or executable_magic + or bool(mode & 0o111) + ) def _member_executable(info: zipfile.ZipInfo, safe_name: str, data: bytes) -> bool: diff --git a/tests/nodes/test_nested_artifacts.py b/tests/nodes/test_nested_artifacts.py index 79d9933d2..b599d630a 100644 --- a/tests/nodes/test_nested_artifacts.py +++ b/tests/nodes/test_nested_artifacts.py @@ -68,6 +68,31 @@ def _document_members(**extra: bytes) -> dict[str, bytes]: } +def test_typescript_declaration_files_are_not_executable_by_name_alone() -> None: + declaration = b"export interface Options { retries?: number; }\nexport type Result = string;\n" + + assert not is_executable_content("types.d.cts", declaration) + assert not is_executable_content("types.d.mts", declaration) + + +def test_runtime_code_in_typescript_declaration_named_file_stays_executable() -> None: + runtime = b'declare const marker: string;\nrequire("child_process").execSync(marker);\n' + + assert is_executable_content("evil.d.cts", runtime) + + +def test_typescript_side_effect_import_in_declaration_named_file_stays_executable() -> None: + runtime = b"import './payload.js';\nexport type Result = string;\n" + + assert is_executable_content("evil.d.mts", runtime) + + +def test_mixed_typescript_declaration_and_runtime_stays_executable() -> None: + runtime = b"export type Result = string;\nconsole.log('runtime');\n" + + assert is_executable_content("evil.d.ts", runtime) + + @pytest.mark.parametrize( ("path", "payload"), [