Skip to content
Merged
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: 2 additions & 2 deletions flatpak/python-modules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ sources:
only-arches:
- x86_64
- type: file
url: https://files.pythonhosted.org/packages/13/f1/a2da3b55acd4ab737bf728c97edaaed5ec1d3c1236acb639dcdfa97e42c7/pypdf-6.16.2-py3-none-any.whl
sha256: c8b09a59399062fb45a1b8156c18a787a10a3dae03ac9674397a226712c94604
url: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl
sha256: 7e5d6e730e7dae87d560a2cee218b852f6498c8be61966f3cd02ead971e48d14
- type: file
url: https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl
sha256: 7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34
Expand Down
2 changes: 1 addition & 1 deletion flatpak/requirements-pinned.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
PySide6==6.11.1
shiboken6==6.11.1
pypdf==6.16.2
pypdf==6.19.0
cryptography==50.0.1
qtawesome==1.4.2
pillow==12.3.0
Expand Down
33 changes: 25 additions & 8 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
PySide6>=6.11.1
# PYSEC-2026-3655 / PYSEC-2026-3656 (fixed in 6.15.0), CVE-2026-82398
# (6.15.0), CVE-2026-84309 (6.16.0) and CVE-2026-84310 / CVE-2026-84311
# (6.16.1). 6.16.1 is the first release clear of all six, so the floor
# is the current 6.16.2 rather than the 6.15.0 the earliest advisory
# alone would suggest.
pypdf>=6.16.2
# (6.16.1). Then PYSEC-2026-4158 (6.17.0), PYSEC-2026-4153 (6.18.0),
# PYSEC-2026-4154 / PYSEC-2026-4155 / PYSEC-2026-4156 (6.18.1) and
# PYSEC-2026-4157 / PYSEC-2026-4159 / PYSEC-2026-4160 (6.19.0), all
# memory or runtime exhaustion on crafted PDFs. 6.19.0 is the first
# release clear of all fourteen; 6.18.1, which build.yml installed for
# every v1.15.0 binary it built (run 34720950897), still carries three.
# "pip-audit -r" audits the newest release that satisfies ">=", not the
# floor, so a floor that admits vulnerable releases audits clean for as
# long as a fixed release exists.
# tests/test_dependency_security_floors.py fails if the floor drops
# below 6.19.0, but only where pytest runs (as of 2026-10-05 no
# workflow on main runs pytest). An advisory published later against
# the floor turns nothing that audits this file red once a fixed
# release exists. Do not lower it.
pypdf>=6.19.0
# GHSA-537c-gmf6-5ccf: signature verification bypass in legacy OpenSSL
# providers fixed in 48.0.1, and PYSEC-2026-3552 / CVE-2026-69247
# (Bleichenbacher oracle in PKCS#7 EnvelopedData decryption, introduced
Expand All @@ -28,9 +39,15 @@ ebooklib>=0.20
# external entities by default, allowing crafted DOCX/PPTX/EPUB/PDF-XMP
# files to read arbitrary local files).
lxml>=6.1.1
# Transitive of cryptography/requests; pinned to address advisories
# PYSEC-2026-141 / 142 / GHSA series for urllib3 < 2.7.0.
urllib3>=2.7.0
# Transitive of urllib3/requests; PYSEC-2026-215 for idna < 3.15.
# No package in this file requires urllib3 and app/ does not import it;
# only requests, pulled in by pip-audit from requirements-dev.txt,
# depends on it. Floor for advisories
# PYSEC-2026-141 / 142 / GHSA series for urllib3 < 2.7.0, then
# PYSEC-2026-4175 / PYSEC-2026-4176 / PYSEC-2026-4177 (published
# 2026-10-01, all affecting 2.7.0), fixed in 2.8.0.
urllib3>=2.8.0
# Same as urllib3: no package in this file requires idna (urllib3 does
# not either) and app/ does not import it; only requests depends on it.
# Floor for PYSEC-2026-215, idna < 3.15.
idna>=3.18
pyinstaller>=6.21.0
177 changes: 177 additions & 0 deletions tests/test_dependency_security_floors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""Security floors in requirements.txt must exclude known-affected releases.

Why this is a test and not left to pip-audit: ``security-deps.yml`` runs
``pip-audit -r requirements.txt --strict``, and pip-audit resolves every
``>=`` requirement to the newest matching release before auditing it.
A floor that still admits a vulnerable release therefore audits clean
for as long as a fixed release exists upstream. Measured on 2026-10-05
with pip-audit 2.10.1: ``pypdf>=6.16.2`` reported no vulnerabilities
(it audited 6.19.0), while ``pypdf==6.16.2`` reported eight.

The floor still matters because every build resolves at build time:
the v1.15.0 binaries (build.yml run 34720950897, all four jobs) shipped
pypdf 6.18.1, which the 6.16.2 floor admitted and which carries three of
the advisories listed below.

What this does NOT catch: an advisory published after the table below
was measured. The test only knows the advisories it lists.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest
from packaging.requirements import Requirement
from packaging.specifiers import Specifier, SpecifierSet
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version

ROOT = Path(__file__).resolve().parent.parent
REQUIREMENTS = ROOT / "requirements.txt"

# PEP 503 name -> (first release from which every later release is clear
# of every advisory OSV lists for the package,
# releases OSV lists as affected that the floor must reject)
# Measured against api.osv.dev on 2026-10-05.
SECURITY_FLOORS = {
# PYSEC-2026-4158 (fixed 6.17.0), PYSEC-2026-4153 (6.18.0),
# PYSEC-2026-4154 / 4155 / 4156 (6.18.1),
# PYSEC-2026-4157 / 4159 / 4160 (6.19.0).
"pypdf": ("6.19.0", ("6.16.2", "6.17.0", "6.18.0", "6.18.1")),
# GHSA-537c-gmf6-5ccf (fixed 48.0.1), PYSEC-2026-3553 / 3554 (49.0.0),
# PYSEC-2026-3552 (introduced 44.0.0, fixed 50.0.0).
"cryptography": ("50.0.0", ("48.0.0", "48.0.1", "49.0.0")),
# PYSEC-2026-4175 / 4176 / 4177, all affecting 2.7.0, all fixed 2.8.0.
"urllib3": ("2.8.0", ("2.6.2", "2.6.3", "2.7.0")),
# PYSEC-2026-87 (XXE, fixed 6.1.0).
"lxml": ("6.1.0", ("6.0.2", "6.0.4")),
# PYSEC-2026-215 (fixed 3.15).
"idna": ("3.15", ("3.13", "3.14")),
}

# pip strips "#" to end of line when it starts the line or follows
# whitespace; "pypdf>=6.19.0 # note" is a valid requirements line.
_INLINE_COMMENT = re.compile(r"(^|\s)#.*$")


def _parse_line(line: str) -> Requirement | None:
s = _INLINE_COMMENT.sub("", line).strip()
return Requirement(s) if s else None


def _requirements(text: str | None = None) -> dict[str, Requirement]:
if text is None:
text = REQUIREMENTS.read_text(encoding="utf-8")
out: dict[str, Requirement] = {}
for line in text.splitlines():
req = _parse_line(line)
if req is None:
continue
name = canonicalize_name(req.name)
assert name not in out, f"{req.name} listed twice"
out[name] = req
return out


def _lowest_admitted(clause: Specifier) -> Version | None:
"""Lowest version ``clause`` can admit, or None if it has no floor.

``==X.*`` admits X's dev and pre-releases, which sort below X, so its
floor is ``X.dev0``. Every other bounding operator admits nothing
below its own version (``>X`` admits nothing at or below X).
"""
if clause.operator in ("<", "<=", "!="):
return None
ver = clause.version
if ver.endswith(".*"):
ver = ver[:-2] + ".dev0"
try:
return Version(ver)
except InvalidVersion:
return None # "===" with a non-PEP 440 string


def _rejects_everything_below(spec: SpecifierSet, minimum: Version) -> bool:
"""True when some clause of ``spec`` admits nothing below ``minimum``.

Clauses are ANDed, so one clause with a high enough floor suffices.
This is operator-agnostic on purpose: ``==``, ``~=`` and ``>`` are as
safe as ``>=`` when their floor clears the minimum.
"""
floors = [f for f in map(_lowest_admitted, spec) if f is not None]
return any(f >= minimum for f in floors)


def _assert_floor(req: Requirement, minimum: Version) -> None:
# A marker makes pip skip the line wherever it evaluates false, so the
# floor would not hold there, whatever the specifier says.
assert req.marker is None, (
f"requirements.txt '{req}' carries an environment marker; the "
f"security floor must apply in every environment"
)
assert _rejects_everything_below(req.specifier, minimum), (
f"requirements.txt '{req}' admits releases below {minimum}, the "
f"first release clear of every known {req.name} advisory"
)


@pytest.mark.parametrize(
("line", "expected"),
[
("pypdf>=6.19.0", True),
("pypdf>=6.19.0,<7", True),
("pypdf==6.19.0", True),
("pypdf~=6.19", True),
("pypdf>6.19.0", True),
("pypdf>=6.19.0 # pinned for PYSEC-2026-4157", True),
("pypdf>=6.20", True),
("pypdf[crypto]>=6.19.0", True),
("pypdf>=6.16.2", False),
("pypdf>=6.18.1", False),
("pypdf>6.18.1", False), # still admits 6.18.1.post1
("pypdf>=6.19.0rc1", False),
("pypdf==6.19.*", False), # admits 6.19.0rc1
("pypdf<7", False),
("pypdf!=6.18.1", False),
("pypdf!=6.20.0", False), # excludes one release, admits all others
("pypdf", False),
('pypdf>=6.19.0 ; python_version<"3"', False),
('pypdf>=6.19.0 ; sys_platform=="win32"', False),
('pypdf>=6.19.0 ; python_version>="3"', False),
],
)
def test_floor_check_accepts_safe_forms_and_rejects_unsafe_ones(line, expected):
req = _parse_line(line)
if expected:
_assert_floor(req, Version("6.19.0"))
else:
with pytest.raises(AssertionError):
_assert_floor(req, Version("6.19.0"))


def test_requirement_names_are_pep503_normalised():
reqs = _requirements("PyPDF>=6.19.0\nPython_Docx.x>=1.2.0 # c\n")
assert set(reqs) == {"pypdf", "python-docx-x"}
with pytest.raises(AssertionError, match="listed twice"):
_requirements("python-docx>=1.2.0\nPython_Docx>=1.2.0\n")


@pytest.mark.parametrize("name", sorted(SECURITY_FLOORS))
def test_specifier_rejects_every_release_below_the_first_clean_one(name):
minimum, _affected = SECURITY_FLOORS[name]
req = _requirements().get(name)
assert req is not None, f"{name} missing from requirements.txt"
_assert_floor(req, Version(minimum))


@pytest.mark.parametrize("name", sorted(SECURITY_FLOORS))
def test_specifier_rejects_every_known_affected_release(name):
_minimum, affected = SECURITY_FLOORS[name]
req = _requirements().get(name)
assert req is not None, f"{name} missing from requirements.txt"
admitted = [v for v in affected if req.specifier.contains(v, prereleases=True)]
assert not admitted, (
f"requirements.txt '{req}' still admits affected releases {admitted}"
)
Loading