Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

# [0.14.0] - 2026-03-30
### Fixed
- Security fix: sanitize relevant metadata inputs to prevent malicious injections

# [0.13.0] - 2025-07-31
### Added
- Support for generating spec files from a local path
Expand Down
17 changes: 10 additions & 7 deletions pyp2spec/local_loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from packaging.metadata import RawMetadata

from pyp2spec.utils import Pyp2specError, CoreMetadataNotFoundError
from pyp2spec.utils import parse_core_metadata, normalize_as_wheel_name
from pyp2spec.utils import parse_core_metadata
from pyp2spec.sanitizer import sanitize


class DirectoryMissingError(Pyp2specError):
Expand Down Expand Up @@ -53,18 +54,20 @@ def load_core_metadata_from_file(wheel_name: str) -> RawMetadata:
def load_dist_data_from_dir(package: str, path: str) -> tuple[str, str, RawMetadata]:
"""
Load distribution data from a given directory.
Return a tuple of sdist name, wheel name, and metadata.
If there's no sdist on the given path, a "not found" string is returned.
Return a tuple of archive name, wheel name, and metadata.
If there's no sdist on the given path, a "..." string is returned.
Raise a FileMissingError if the wheel file cannot be found in the directory.
"""
source_path = _resolve_and_check_if_dir_exists(path)
pkgname = normalize_as_wheel_name(package)
pkgname = sanitize("wheel_name", package)
# sdist name is only needed as a source in specfile
# if not present, we can still generate a good-enough file
try:
sdist_name = str(_look_up_file_in_dir(pkgname, source_path, "tar.gz"))
sdist_path = str(_look_up_file_in_dir(pkgname, source_path, "tar.gz"))
sdist_basename = Path(sdist_path).name
archive_name = sanitize("archive_name", sdist_basename)
except FileMissingError:
sdist_name = "..."
archive_name = "..."
wheel_name = str(_look_up_file_in_dir(pkgname, source_path, "whl"))
metadata = load_core_metadata_from_file(wheel_name)
return sdist_name, wheel_name, metadata
return archive_name, wheel_name, metadata
30 changes: 21 additions & 9 deletions pyp2spec/pyp2conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
from requests import Session

from pyp2spec.license_processor import check_compliance, resolve_license_expression
from pyp2spec.utils import Pyp2specError, normalize_name, get_extras, get_summary_or_placeholder
from pyp2spec.utils import Pyp2specError, get_extras, get_summary_or_placeholder
from pyp2spec.utils import prepend_name_with_python, archive_name
from pyp2spec.utils import has_abi_tag, contains_wheel_with_abi_tag, resolve_url, create_compat_name
from pyp2spec.utils import warn, caution, inform, yay
from pyp2spec.pypi_loaders import load_from_pypi, load_core_metadata_from_pypi, CoreMetadataNotFoundError
from pyp2spec.utils import CoreMetadataNotFoundError
from pyp2spec.pypi_loaders import load_from_pypi, load_core_metadata_from_pypi
from pyp2spec.local_loaders import load_dist_data_from_dir
from pyp2spec.sanitizer import sanitize


@dataclass
Expand Down Expand Up @@ -46,14 +48,23 @@ def prepare_package_info(data: RawMetadata | dict) -> PackageInfo:
project_urls["home_page"] = homepage
if (not homepage and (homepage := data.get("package_url", ""))):
project_urls["home_page"] = homepage

# Sanitize all PyPI metadata to prevent injection attacks
raw_name = data.get("name", "")
raw_version = data.get("version", "")
raw_summary = get_summary_or_placeholder(data.get("summary", ""))
raw_url = resolve_url(project_urls)
raw_extras = get_extras(data.get("provides_extra", []), data.get("requires_dist", []))
raw_license = resolve_license_expression(data)

return PackageInfo(
name=normalize_name(data.get("name", "")),
version=data.get("version", ""),
summary=get_summary_or_placeholder(data.get("summary", "")),
url=resolve_url(project_urls),
extras=get_extras(data.get("provides_extra", []), data.get("requires_dist", [])),
name=sanitize("name", raw_name),
version=sanitize("version", raw_version),
summary=sanitize("summary", raw_summary),
url=sanitize("url", raw_url),
extras=sanitize("extras", raw_extras),
license_files_present=bool(data.get("license_files")),
license=resolve_license_expression(data)
license=sanitize("license", raw_license) if raw_license else None
)


Expand All @@ -66,6 +77,7 @@ def gather_package_info(core_metadata: RawMetadata | None, pypi_package_data: di


def create_package_from_dir(package: str, path: str) -> PackageInfo:
# sdist_name and wheel_name are sanitized when loaded
sdist_name, wheel_name, core_metadata = load_dist_data_from_dir(package, path)
pkg = gather_package_info(core_metadata, None)
pkg.archful = has_abi_tag(str(wheel_name))
Expand All @@ -77,7 +89,7 @@ def create_package_from_dir(package: str, path: str) -> PackageInfo:
def create_package_from_pypi(core_metadata: RawMetadata | None, pypi_pkg_data: dict) -> PackageInfo:
pkg = gather_package_info(core_metadata, pypi_pkg_data)
pkg.archful = contains_wheel_with_abi_tag(pypi_pkg_data["urls"])
pkg.archive_name = archive_name(pypi_pkg_data["urls"])
pkg.archive_name = sanitize("archive_name", archive_name(pypi_pkg_data["urls"]))
pkg.source = "PyPI"
return pkg

Expand Down
105 changes: 105 additions & 0 deletions pyp2spec/sanitizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""
Security module for sanitizing PyPI metadata before rendering into RPM spec files.
"""
from __future__ import annotations

import re
from pathlib import Path

from pyp2spec.utils import MissingPackageNameError


# Shell metacharacters to remove (dangerous for command injection)
# Note: { } are preserved for RPM macros like %%{version}
DANGEROUS_CHARS = r'[$`|;&<>()\[\]\\\'"]'


def _sanitize_base(text: str, allow_spaces: bool = False) -> str:
"""Core sanitization function for all spec file fields.

text: The input text to sanitize
allow_spaces: Whether to preserve spaces
Returns a sanitized text safe for spec file insertion
"""
if not text:
return text

# Remove newlines (convert to spaces first, then handle below)
text = text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')

# Remove control characters
text = ''.join(c for c in text if ord(c) >= 32)

# Remove dangerous %() execution patterns
text = re.sub(r'%\([^)]*\)?', '', text)

# Escape % signs for RPM
text = text.replace('%', '%%')

# Remove shell metacharacters (preserves Unicode)
text = re.sub(DANGEROUS_CHARS, '_', text)

if not allow_spaces:
text = text.replace(' ', '')

return text.strip()


def _sanitize_with_spaces(dirty_string: str) -> str:
return _sanitize_base(dirty_string, allow_spaces=True)


def _sanitize_name(name: str) -> str:
"""Sanitize package name for RPM spec files.

Returns a name normalized according to PEP 503.
"""
if not name:
raise MissingPackageNameError("Cannot create a package without a name")

result = _sanitize_base(name.lower())
# Normalize separators and remove leading/trailing ones
result = re.sub(r"[-_.]+", "-", result).strip('-_')
return result


def _sanitize_wheel_name(name: str) -> str:
"""Normalize as in the wheel specification:
https://packaging.python.org/en/latest/specifications/binary-distribution-format/#escaping-and-unicode
PEP 625 specifies sdist names to this format."""

return _sanitize_name(name).replace("-", "_")


def _sanitize_archive_name(archive_name: str) -> str:
"""Sanitize archive filenames - prevent path traversal."""
archive_name = archive_name.replace('\\', '/')
safe_name = Path(archive_name).name

return _sanitize_base(safe_name)


def _sanitize_extras_list(extras: list[str]) -> list[str]:
return [clean for extra in extras if (clean := _sanitize_base(extra))]


def sanitize(field_name: str, value: str | list[str]) -> str | list[str]:
"""Main sanitization entry point for all spec file fields.

field_name: The name of the field being sanitized
value: The value to sanitize
Returns a sanitized value safe for spec file insertion
"""
sanitizers = {
"name": _sanitize_name,
"wheel_name": _sanitize_wheel_name,
"archive_name": _sanitize_archive_name,
"license": _sanitize_with_spaces,
"summary": _sanitize_with_spaces,
"version": _sanitize_base,
"url": _sanitize_base,
"extras": _sanitize_extras_list,
}

sanitizer_func = sanitizers.get(field_name)
return sanitizer_func(value)
17 changes: 0 additions & 17 deletions pyp2spec/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,6 @@ class CoreMetadataNotFoundError(Pyp2specError):
"""Raised when there's no Metadata file available on PyPI API"""


def normalize_name(package_name: str) -> str:
"""Normalize given package name as defined in PEP 503.
The resulting string better conforms with Fedora's Packaging Guidelines."""

if not package_name:
raise MissingPackageNameError("Cannot create a package without a name")
return re.sub(r"[-_.]+", "-", package_name).lower()


def normalize_as_wheel_name(package_name: str) -> str:
"""Normalize as in the wheel specification:
https://packaging.python.org/en/latest/specifications/binary-distribution-format/#escaping-and-unicode
PEP 625 specifies sdist names to this format."""

return normalize_name(package_name).replace("-", "_")


def prepend_name_with_python(name: str, python_alt_version: str | None = None) -> str:
"""Create a component name for the specfile.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pyp2spec"
version = "0.13.0"
version = "0.14.0"
description = "Generate a valid Fedora specfile from Python package from PyPI"
readme = { file = "README.md", content-type = "text/markdown" }
authors = [
Expand Down
4 changes: 1 addition & 3 deletions tests/test_local_loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,7 @@ def test_load_core_metadata_from_file():
def test_load_dist_data_from_dir():
test_dir = "tests/local"
sdist, wheel, data = load_dist_data_from_dir("local_test", test_dir)
# Keep just the latest parts of the full file paths
sdist = sdist.split("/")[-3:]
assert sdist == ["tests", "local", "local_test-0.12.2.tar.gz"]
assert sdist == "local_test-0.12.2.tar.gz"
wheel = wheel.split("/")[-1]
assert wheel == "local_test-0.12.2-py3-none-any.whl"
assert data["version"] == "0.12.2"
Loading
Loading