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 1/2] 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") From fe1b0bd9ff2981198abcc978bf343a8d9426bee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Wed, 23 Sep 2026 19:33:24 +0300 Subject: [PATCH 2/2] fix: classify only proven TypeScript declarations as inert 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 | 23 +++++++++++------------ tests/nodes/test_nested_artifacts.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/skillspector/nested_artifacts.py b/src/skillspector/nested_artifacts.py index 98e28e603..1ffd03b55 100644 --- a/src/skillspector/nested_artifacts.py +++ b/src/skillspector/nested_artifacts.py @@ -384,15 +384,15 @@ def _zip_member_is_link(info: zipfile.ZipInfo) -> bool: ) _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*\([^)]*\)" +_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, ) @@ -411,9 +411,8 @@ def _looks_like_typescript_declaration(path: str, data: bytes) -> bool: 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)) + 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: diff --git a/tests/nodes/test_nested_artifacts.py b/tests/nodes/test_nested_artifacts.py index 71d646fb8..280920018 100644 --- a/tests/nodes/test_nested_artifacts.py +++ b/tests/nodes/test_nested_artifacts.py @@ -80,6 +80,18 @@ def test_runtime_code_in_typescript_declaration_named_file_stays_executable() -> 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) + + def _with_unsupported_compression(data: bytes, method: int = 99) -> bytes: encoded = bytearray(data) local_header = encoded.find(b"PK\x03\x04")