Skip to content

Commit 530edc6

Browse files
committed
feat: prevent uploading PyPI tokens in common places
Minimal implementaiton detecting PyPI API tokens in the commonly used places using the Yara scanner.
1 parent 53babdb commit 530edc6

3 files changed

Lines changed: 135 additions & 11 deletions

File tree

tests/unit/utils/test_scanner.py

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
import tarfile
66
import zipfile
77

8+
from uuid import uuid4
9+
10+
import pymacaroons
811
import pytest
912
import yara_x
1013

14+
from warehouse.macaroons import caveats
1115
from warehouse.utils import scanner
1216

1317

@@ -18,6 +22,41 @@ def rules():
1822
return compiled
1923

2024

25+
def _generate_token(domain="pypi.org", projects_scope=False):
26+
raw_macaroon = pymacaroons.Macaroon(
27+
location=domain,
28+
identifier=str(uuid4()),
29+
key=b"fake key",
30+
version=pymacaroons.MACAROON_V2,
31+
)
32+
33+
if projects_scope:
34+
caveats_ = [caveats.ProjectID(project_ids=[str(uuid4()) for _ in range(3)])]
35+
else:
36+
caveats_ = [
37+
caveats.ProjectName(normalized_names=[f"project-{i}" for i in range(3)]),
38+
caveats.RequestUser(user_id=str(uuid4())),
39+
]
40+
for caveat in caveats_:
41+
raw_macaroon.add_first_party_caveat(caveats.serialize(caveat))
42+
43+
return f"pypi-{raw_macaroon.serialize()}"
44+
45+
46+
@pytest.fixture(
47+
scope="module", params=[False, True], ids=["user-scope", "projects-scope"]
48+
)
49+
def pypi_token(request):
50+
return _generate_token(domain="pypi.org", projects_scope=request.param)
51+
52+
53+
@pytest.fixture(
54+
scope="module", params=[False, True], ids=["user-scope", "projects-scope"]
55+
)
56+
def localhost_token(request):
57+
return _generate_token(domain="localhost", projects_scope=request.param)
58+
59+
2160
def _make_wheel(tmp_path, files_dict, name="fake_package", version="1.0"):
2261
whl_path = str(tmp_path / f"{name}-{version}-py3-none-any.whl")
2362
with zipfile.ZipFile(whl_path, "w") as zfp:
@@ -248,22 +287,22 @@ def test_clean_archive_no_matches(self, tmp_path, rules):
248287
)
249288
assert scanner.scan_archive(whl, rules=rules) == []
250289

251-
def test_skips_non_python_files_in_wheel(self, tmp_path, rules):
290+
def test_skips_excluded_files_in_wheel(self, tmp_path, rules):
252291
whl = _make_wheel(
253292
tmp_path,
254293
{
255294
"pkg/data.json": "__pyarmor__(__name__, __file__, b'x')",
256-
"pkg/readme.txt": "__pyarmor_enter__()",
295+
"pkg/module.so": b"__pyarmor_enter__()",
257296
},
258297
)
259298
assert scanner.scan_archive(whl, rules=rules) == []
260299

261-
def test_skips_non_python_files_in_tarball(self, tmp_path, rules):
300+
def test_skips_excluded_files_in_tarball(self, tmp_path, rules):
262301
tar = _make_tarball(
263302
tmp_path,
264303
{
265304
"fake-1.0/data.json": "__pyarmor__(__name__, __file__, b'x')",
266-
"fake-1.0/readme.txt": "__pyarmor_enter__()",
305+
"fake-1.0/module.so": b"__pyarmor_enter__()",
267306
},
268307
)
269308
assert scanner.scan_archive(tar, rules=rules) == []
@@ -396,3 +435,48 @@ def test_spoofed_file_size_does_not_bypass_scan(self, tmp_path, rules):
396435
assert len(matches) == 1
397436
assert matches[0][0] == "pkg/__init__.py"
398437
assert "pyarmor_encrypted" in matches[0][1]
438+
439+
440+
_FILENAMES_TO_SCAN = [
441+
"setup.py",
442+
"README.md",
443+
"PUBLISHING.RST",
444+
"publish.sh",
445+
"info.txt",
446+
".env",
447+
]
448+
449+
450+
class TestPyPITokenDetection:
451+
# TODO: separated METADATA/PKG-INFO tests with correct paths
452+
@pytest.mark.parametrize("filename", [*_FILENAMES_TO_SCAN, "METADATA"])
453+
def test_detects_pypi_token_in_wheel(self, tmp_path, rules, pypi_token, filename):
454+
whl = _make_wheel(tmp_path, {f"pkg/{filename}": pypi_token})
455+
matches = scanner.scan_archive(whl, rules=rules)
456+
assert len(matches) == 1
457+
assert matches[0][0] == f"pkg/{filename}"
458+
assert "secrets_pypi_token" in matches[0][1]
459+
460+
@pytest.mark.parametrize("filename", [*_FILENAMES_TO_SCAN, "PKG-INFO"])
461+
def test_detects_pypi_token_in_tarball(self, tmp_path, rules, pypi_token, filename):
462+
tar = _make_tarball(tmp_path, {f"fake-1.0/pkg/{filename}": pypi_token})
463+
matches = scanner.scan_archive(tar, rules=rules)
464+
assert len(matches) == 1
465+
assert matches[0][0] == f"fake-1.0/pkg/{filename}"
466+
assert "secrets_pypi_token" in matches[0][1]
467+
468+
@pytest.mark.parametrize("filename", [*_FILENAMES_TO_SCAN, "METADATA"])
469+
def test_ignores_localhost_token_in_wheel(
470+
self, tmp_path, rules, localhost_token, filename
471+
):
472+
whl = _make_wheel(tmp_path, {f"pkg/{filename}": localhost_token})
473+
matches = scanner.scan_archive(whl, rules=rules)
474+
assert len(matches) == 0
475+
476+
@pytest.mark.parametrize("filename", [*_FILENAMES_TO_SCAN, "PKG-INFO"])
477+
def test_ignores_localhost_token_in_tarball(
478+
self, tmp_path, rules, localhost_token, filename
479+
):
480+
tar = _make_tarball(tmp_path, {f"fake-1.0/pkg/{filename}": localhost_token})
481+
matches = scanner.scan_archive(tar, rules=rules)
482+
assert len(matches) == 0

warehouse/utils/scanner.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,21 @@
1616
# YARA rules directory
1717
_RULES_DIR = Path(__file__).parent / "scanner_rules"
1818

19-
# Extensions to scan inside archives. Python source (.py) for source-level
20-
# rules (e.g. pyarmor), and .pye for SourceDefender-encrypted files.
21-
_SCAN_EXTENSIONS = {".py", ".pye"}
19+
# Extensions to scan inside archives.
20+
_SCAN_EXTENSIONS = {
21+
# Python source for source-level rules (e.g. pyarmor)
22+
".py",
23+
# .pye for SourceDefender-encrypted files
24+
".pye",
25+
# Different textual files for common places where PyPI tokens are accidentally left.
26+
".md",
27+
".rst",
28+
".env",
29+
".sh",
30+
".txt",
31+
"METADATA",
32+
"PKG-INFO",
33+
}
2234

2335
# Max size of individual file to scan inside archive (5 MiB)
2436
_SCAN_MAX_FILE_SIZE = 5 * 1024 * 1024
@@ -78,8 +90,10 @@ def iter_zip_members(zfp: zipfile.ZipFile) -> typing.Iterator[tuple[str, int, by
7890
for entry in zfp.infolist():
7991
if entry.is_dir():
8092
continue
81-
ext = Path(entry.filename).suffix.lower()
82-
if ext not in _SCAN_EXTENSIONS:
93+
path = Path(entry.filename)
94+
ext = path.suffix.lower()
95+
# Names like "METADATA", ".env" have empty suffix
96+
if ext not in _SCAN_EXTENSIONS and path.name not in _SCAN_EXTENSIONS:
8397
continue
8498
data = zfp.read(entry.filename)
8599
yield entry.filename, len(data), data
@@ -90,8 +104,10 @@ def iter_tar_members(tar: tarfile.TarFile) -> typing.Iterator[tuple[str, int, by
90104
for member in tar.getmembers():
91105
if not member.isfile():
92106
continue
93-
ext = Path(member.name).suffix.lower()
94-
if ext not in _SCAN_EXTENSIONS:
107+
path = Path(member.name)
108+
ext = path.suffix.lower()
109+
# Names like "PKG-INFO", ".env" have empty suffix
110+
if ext not in _SCAN_EXTENSIONS and path.name not in _SCAN_EXTENSIONS:
95111
continue
96112
f = tar.extractfile(member)
97113
if f is None: # pragma: no cover
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
rule secrets_pypi_token
2+
{
3+
meta:
4+
description = "Detects PyPI API tokens exposed in source code."
5+
author = "Kamil Mankowski"
6+
message = "We have detected a PyPI API token exposed in the uploaded file. Publishing it would allow anyone to perform actions on your behalf. For your own security, please revoke the token immediately."
7+
8+
strings:
9+
// Regex adapted from trufflehog's PyPI token detector
10+
// Intentionally not derived from the official Token format definition to spare unnecessary matches.
11+
// Pre-computed head ensures we match actual pypi.org tokens
12+
// https://github.com/trufflesecurity/trufflehog/blob/main/pkg/detectors/pypi/pypi.go
13+
$pypi_token = /pypi-AgEIcHlwaS5vcmcCJ[a-zA-Z0-9-_]{150,157}/
14+
15+
// TODO: look if there are test tokens in use we should exclude
16+
// $test_token = "pypi-AgEIcHlwaS5vcmcCJxxx"
17+
18+
condition:
19+
$pypi_token
20+
// If we want to allow some test-only tokens, we can use:
21+
// and for all i in (1 .. #pypi_token) : (
22+
// not $test_token at @pypi_token[i]
23+
// )
24+
}

0 commit comments

Comments
 (0)