From 692390589b1e20ca09154b14cdcb50f072094d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Tue, 22 Sep 2026 01:17:33 +0300 Subject: [PATCH] fix: inspect TypeScript declaration content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Efe Gökdemir --- src/skillspector/nested_artifacts.py | 40 +++++++++++++++++++++++++++- tests/nodes/test_nested_artifacts.py | 14 ++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/skillspector/nested_artifacts.py b/src/skillspector/nested_artifacts.py index fc02bc3f6..98e28e603 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 @@ -382,6 +383,38 @@ 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_MARKERS = re.compile( + r"\b(?:declare|interface|type|import\s+type|export\s+" + r"(?:declare|interface|type|namespace))\b" +) +_TYPESCRIPT_RUNTIME_MARKERS = re.compile( + r"\b(?:require|eval|Function|module\.exports|process\.|console\." + r"|fetch|new|setTimeout|setInterval|exec(?:Sync)?|spawn(?:Sync)?|" + r"child_process)\b|\b(?:const|let|var)\s+[A-Za-z_$]" + r"[\w$]*\s*=|(?:^|[;{}])\s*[A-Za-z_$][\w$]*\s*\([^)]*\)" +) + + +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) + if not text.strip() or _TYPESCRIPT_RUNTIME_MARKERS.search(text): + return False + return bool(_TYPESCRIPT_DECLARATION_MARKERS.search(text)) + def has_binary_executable_magic(data: bytes) -> bool: """Return whether canonical bytes begin with supported executable magic.""" @@ -392,7 +425,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 fd292b11b..71d646fb8 100644 --- a/tests/nodes/test_nested_artifacts.py +++ b/tests/nodes/test_nested_artifacts.py @@ -27,6 +27,7 @@ _apply_inventory_overrides, _mark_inventory_exception, inspect_nested_artifacts, + is_executable_content, ) from skillspector.nodes.analyzers.static_patterns_supply_chain import ( _analyze_concealed_executables, @@ -66,6 +67,19 @@ 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 _with_unsupported_compression(data: bytes, method: int = 99) -> bytes: encoded = bytearray(data) local_header = encoded.find(b"PK\x03\x04")