Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/skillspector/nested_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from __future__ import annotations

import io
import re
import stat
import struct
import time
Expand Down Expand Up @@ -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."""
Expand All @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions tests/nodes/test_nested_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down
Loading