pipcanary 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastian Kuebeck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ include src/pipcanary/sbpip_scan.sh
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipcanary
3
+ Version: 0.0.3
4
+ Summary: Supply Chain Attack prevention tool
5
+ Author-email: Sebastian Kuebeck <sebastian.kuebeck@encab.io>
6
+ Project-URL: Homepage, https://github.com/sebastian-kuebeck/pipcanary
7
+ Project-URL: Documentation, https://github.com/sebastian-kuebeck/pipcanary
8
+ Project-URL: Bug Tracker, https://github.com/sebastian-kuebeck/pipcanary
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Programming Language :: Python :: 3.15
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: virtualenv~=21.2.0
21
+ Dynamic: license-file
22
+
23
+ # PipCanary
24
+
25
+ ## Features
26
+
27
+ - Detects Supply Chain Attacks in Python packages
28
+
29
+ - Makes sure that new package releases are only installed after a cool down period, so secuity scanners have time to detect vulnerabilities
30
+
31
+ ## Maturity
32
+
33
+ The project is in early stages. However, it's safer to use this than pip, poetry or uv alone.
34
+
35
+ ## Requirements
36
+
37
+ - Linux
38
+
39
+ - [Python](https://www.python.org/) 3.10 or higher
40
+
41
+ - [bubblewrap](https://github.com/containers/bubblewrap)
42
+
43
+ - [strace](https://strace.io)
44
+
45
+ - [pip](https://pip.pypa.io/en/stable/getting-started/)
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install pipcanary
51
+ ```
52
+
53
+ ## Execution
54
+
55
+ Check your requirements for potential Supply Chain Attacks
56
+
57
+ ```bash
58
+ pipcanary -r requirements.txt
59
+ ```
60
+
61
+ Sample output when all is fine...
62
+
63
+ ```text
64
+ ...
65
+ All packages appear to be safe!
66
+ ```
67
+
68
+ Sample output if a potential attack is detected...
69
+
70
+ ```text
71
+ ...
72
+ Found suspicious access to /root/.ssh in package evilpack
73
+
74
+ This could be dangerous!!!
75
+ Don't install this package under any circumstances until you know for sure that this is a false positive!
76
+ In doubt, contact the package maintainers!
77
+ ```
78
+
79
+ Sample output when packages were updated during the cooling of phase of one week...
80
+
81
+ ```text
82
+ ...
83
+ Package click 8.3.2 was updated too recently: 2026-04-03T19:14:45.
84
+ It might be safer to use an older version.
85
+ Consider click<=8.3.1 or earlier and check for potential known vulnerabilities of this version.
86
+ If you are certain that the latest upload is safe, add the following argument...
87
+ --allow-upload-time='click<=2026-04-03T19:14:45'
88
+
89
+ Package Werkzeug 3.1.8 was updated too recently: 2026-04-02T18:49:14.
90
+ It might be safer to use an older version.
91
+ Consider Werkzeug<=3.1.7 or earlier and check for potential known vulnerabilities of this version.
92
+ If you are certain that the latest upload is safe, add the following argument...
93
+ --allow-upload-time='Werkzeug<=2026-04-02T18:49:14'
94
+
95
+ The following packages were uploaded too recently: click, Werkzeug
96
+ ```
97
+
98
+ ## Similar Projects
99
+
100
+ - [pip-audit](https://github.com/pypa/pip-audit)
101
+
102
+ ## Further Information on PyPi Suppy Chaion Attacks
103
+
104
+ - [How a Poisoned Security Scanner Became the Key to Backdooring LiteLLM](https://snyk.io/de/articles/poisoned-security-scanner-backdooring-litellm/)
105
+ - [The Team PCP Snowball Effect: A Quantitative Analysis](https://blog.gitguardian.com/team-pcp-snowball-analysis/)
@@ -0,0 +1,83 @@
1
+ # PipCanary
2
+
3
+ ## Features
4
+
5
+ - Detects Supply Chain Attacks in Python packages
6
+
7
+ - Makes sure that new package releases are only installed after a cool down period, so secuity scanners have time to detect vulnerabilities
8
+
9
+ ## Maturity
10
+
11
+ The project is in early stages. However, it's safer to use this than pip, poetry or uv alone.
12
+
13
+ ## Requirements
14
+
15
+ - Linux
16
+
17
+ - [Python](https://www.python.org/) 3.10 or higher
18
+
19
+ - [bubblewrap](https://github.com/containers/bubblewrap)
20
+
21
+ - [strace](https://strace.io)
22
+
23
+ - [pip](https://pip.pypa.io/en/stable/getting-started/)
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install pipcanary
29
+ ```
30
+
31
+ ## Execution
32
+
33
+ Check your requirements for potential Supply Chain Attacks
34
+
35
+ ```bash
36
+ pipcanary -r requirements.txt
37
+ ```
38
+
39
+ Sample output when all is fine...
40
+
41
+ ```text
42
+ ...
43
+ All packages appear to be safe!
44
+ ```
45
+
46
+ Sample output if a potential attack is detected...
47
+
48
+ ```text
49
+ ...
50
+ Found suspicious access to /root/.ssh in package evilpack
51
+
52
+ This could be dangerous!!!
53
+ Don't install this package under any circumstances until you know for sure that this is a false positive!
54
+ In doubt, contact the package maintainers!
55
+ ```
56
+
57
+ Sample output when packages were updated during the cooling of phase of one week...
58
+
59
+ ```text
60
+ ...
61
+ Package click 8.3.2 was updated too recently: 2026-04-03T19:14:45.
62
+ It might be safer to use an older version.
63
+ Consider click<=8.3.1 or earlier and check for potential known vulnerabilities of this version.
64
+ If you are certain that the latest upload is safe, add the following argument...
65
+ --allow-upload-time='click<=2026-04-03T19:14:45'
66
+
67
+ Package Werkzeug 3.1.8 was updated too recently: 2026-04-02T18:49:14.
68
+ It might be safer to use an older version.
69
+ Consider Werkzeug<=3.1.7 or earlier and check for potential known vulnerabilities of this version.
70
+ If you are certain that the latest upload is safe, add the following argument...
71
+ --allow-upload-time='Werkzeug<=2026-04-02T18:49:14'
72
+
73
+ The following packages were uploaded too recently: click, Werkzeug
74
+ ```
75
+
76
+ ## Similar Projects
77
+
78
+ - [pip-audit](https://github.com/pypa/pip-audit)
79
+
80
+ ## Further Information on PyPi Suppy Chaion Attacks
81
+
82
+ - [How a Poisoned Security Scanner Became the Key to Backdooring LiteLLM](https://snyk.io/de/articles/poisoned-security-scanner-backdooring-litellm/)
83
+ - [The Team PCP Snowball Effect: A Quantitative Analysis](https://blog.gitguardian.com/team-pcp-snowball-analysis/)
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=63.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pipcanary"
7
+ version = "0.0.3"
8
+ authors = [
9
+ { name="Sebastian Kuebeck", email="sebastian.kuebeck@encab.io" },
10
+ ]
11
+ license-files = ['LICENSE']
12
+ description = "Supply Chain Attack prevention tool"
13
+ readme = "README.md"
14
+ requires-python = ">=3.10"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: 3.15",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Operating System :: POSIX :: Linux",
24
+ ]
25
+
26
+ dependencies = ["virtualenv~=21.2.0"]
27
+
28
+ [project.urls]
29
+ "Homepage" = "https://github.com/sebastian-kuebeck/pipcanary"
30
+ "Documentation" = "https://github.com/sebastian-kuebeck/pipcanary"
31
+ "Bug Tracker" = "https://github.com/sebastian-kuebeck/pipcanary"
32
+
33
+ [project.scripts]
34
+ pipcanary = "pipcanary.pipcanary:pipcanary"
35
+
36
+ [tool.setuptools.package-data]
37
+ pipcanary = ["src/pipcanary/sbpip_scan.sh"]
38
+
39
+ [tool.ruff]
40
+ line-length = 120
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,4 @@
1
+ from .pipcanary import pipcanary
2
+
3
+ if __name__ == "__main__":
4
+ pipcanary()
@@ -0,0 +1,22 @@
1
+ import sys
2
+ import pkgutil
3
+
4
+ from distutils.sysconfig import get_python_lib
5
+
6
+
7
+ def load_modules(dirname):
8
+ for importer, package_name, _ in pkgutil.iter_modules([dirname]):
9
+ if package_name not in sys.modules:
10
+ try:
11
+ print("Loading package: %s..." % package_name)
12
+ print("Package: %s" % package_name, file=sys.stderr)
13
+ importer.find_module(package_name).load_module( # type: ignore
14
+ package_name
15
+ )
16
+ del sys.modules[package_name]
17
+ except ImportError:
18
+ pass
19
+
20
+
21
+ if __name__ == "__main__":
22
+ load_modules(get_python_lib())
@@ -0,0 +1,231 @@
1
+ import json
2
+
3
+ from typing import Dict, Any, Optional, List, Union
4
+ from urllib.request import urlopen
5
+ from urllib.error import URLError, HTTPError
6
+ from datetime import datetime, timedelta
7
+ from abc import ABC, abstractmethod
8
+
9
+
10
+ class Upload:
11
+ def __init__(self, version: str, upload_time: datetime) -> None:
12
+ self.version = version
13
+ self.upload_time = upload_time
14
+
15
+ @staticmethod
16
+ def from_json(version: str, data: Dict[str, Any]) -> "Upload":
17
+ upload_time = data["upload_time"]
18
+ return Upload(version, datetime.fromisoformat(upload_time))
19
+
20
+
21
+ class Release:
22
+ def __init__(self, version: str, uploads: List[Upload]) -> None:
23
+ self.version = version
24
+ self.uploads = uploads
25
+
26
+ @property
27
+ def latest_upload_date(self) -> Optional[datetime]:
28
+ if self.uploads:
29
+ return max([u.upload_time for u in self.uploads])
30
+ else:
31
+ return None
32
+
33
+ @staticmethod
34
+ def from_json(version: str, data: List[Dict[str, Any]]) -> "Release":
35
+ uploads: List[Upload] = []
36
+ for upload_data in data:
37
+ uploads.append(Upload.from_json(version, upload_data))
38
+ return Release(version, uploads)
39
+
40
+
41
+ class PackageInfo:
42
+ def __init__(
43
+ self, latest_version: Optional[str], releases: Dict[str, Release]
44
+ ) -> None:
45
+ self.latest_version = latest_version
46
+ self.releases = releases
47
+
48
+ @staticmethod
49
+ def from_json(data: Dict[str, Any]) -> "PackageInfo":
50
+ releases: Dict[str, Release] = {}
51
+ for version, release_list in data.get("releases", {}).items():
52
+ releases[version] = Release.from_json(version, release_list)
53
+
54
+ latest_version = data["info"]["version"]
55
+
56
+ if latest_version:
57
+ return PackageInfo(latest_version, releases)
58
+ else:
59
+ return PackageInfo(None, {})
60
+
61
+ def latest_upload_date(self, version: str) -> Optional[datetime]:
62
+ if self.latest_version and self.releases:
63
+ release = self.releases.get(version)
64
+ if release:
65
+ return release.latest_upload_date
66
+
67
+ @property
68
+ def uploads(self) -> List[Upload]:
69
+ uploads = []
70
+ for _, release in self.releases.items():
71
+ uploads.extend(release.uploads)
72
+ return uploads
73
+
74
+
75
+ class PackageSource(ABC):
76
+ @abstractmethod
77
+ def download_package_info(self, package_name: str) -> Optional[PackageInfo]:
78
+ pass
79
+
80
+
81
+ class PackageDownloadError(Exception):
82
+ def __init__(self, package_name: str, msg: str, parent: Exception) -> None:
83
+ super().__init__(msg)
84
+ self.package_name = package_name
85
+ self.parent = parent
86
+
87
+
88
+ class PypiPackageSource(PackageSource):
89
+ def download_package_info(self, package_name: str) -> Optional[PackageInfo]:
90
+ try:
91
+ response = urlopen(f"https://pypi.org/pypi/{package_name}/json")
92
+ package_info_data = json.load(response)
93
+ return PackageInfo.from_json(package_info_data)
94
+ except HTTPError as e:
95
+ if e.code == 404:
96
+ return None
97
+ else:
98
+ raise PackageDownloadError(package_name, str(e), e)
99
+ except URLError as e:
100
+ raise PackageDownloadError(package_name, str(e), e)
101
+
102
+
103
+ class PackageAgumentError(Exception):
104
+ pass
105
+
106
+
107
+ class Package:
108
+ def __init__(self, name: str, version: str, source: PackageSource) -> None:
109
+ self.name = name
110
+ self.version = version
111
+ self.source = source
112
+ self._info_available = False
113
+ self._info: Optional[PackageInfo] = None
114
+
115
+ @staticmethod
116
+ def from_json(record: Dict[str, str], source: PackageSource) -> "Package":
117
+ return Package(record["name"], record["version"], source)
118
+
119
+ @property
120
+ def info(self) -> Optional[PackageInfo]:
121
+ if not self._info_available:
122
+ self._info = self.source.download_package_info(self.name)
123
+ self._info_available = True
124
+
125
+ return self._info
126
+
127
+ @property
128
+ def latest_upload_date(self) -> Optional[datetime]:
129
+ if self.info:
130
+ return self.info.latest_upload_date(self.version)
131
+
132
+ def latest_possible_upload(self, latest_upload_time: datetime) -> Optional[Upload]:
133
+ if self.info:
134
+ uploads = sorted(
135
+ self.info.uploads, key=lambda u: u.upload_time, reverse=True
136
+ )
137
+ for upload in uploads:
138
+ if upload.upload_time <= latest_upload_time:
139
+ return upload
140
+
141
+ def __str__(self) -> str:
142
+ return f"{self.name} {self.version}"
143
+
144
+
145
+ class PackageCheckObserver(ABC):
146
+ @abstractmethod
147
+ def package_not_found(self, package: Package):
148
+ pass
149
+
150
+ @abstractmethod
151
+ def package_too_recently(
152
+ self, package: Package, upload_time: datetime, latest_upload_time: datetime
153
+ ):
154
+ pass
155
+
156
+
157
+ class PackageSelection:
158
+ COOL_DOWN_PHASE_DAYS = 7
159
+
160
+ def __init__(
161
+ self,
162
+ max_upload_time: Optional[str] = None,
163
+ cool_down_phase_days: Optional[int] = None,
164
+ allowed_upload_times: Optional[Union[List[str], str]] = None,
165
+ current_time: Optional[datetime] = None,
166
+ ) -> None:
167
+ self.current_time: datetime = current_time or datetime.now()
168
+ self.cool_down_phase_days = cool_down_phase_days or self.COOL_DOWN_PHASE_DAYS
169
+ try:
170
+ self._max_upload_time: Optional[datetime] = (
171
+ datetime.fromisoformat(max_upload_time) if max_upload_time else None
172
+ )
173
+ except ValueError:
174
+ raise PackageAgumentError(
175
+ "Malformed datetime passed with argument max_upload_time"
176
+ )
177
+
178
+ self._max_upload_time_for: Dict[str, datetime] = {}
179
+
180
+ if isinstance(allowed_upload_times, str):
181
+ allowed_upload_times = [allowed_upload_times]
182
+
183
+ rules = allowed_upload_times or []
184
+ for rule in rules:
185
+ try:
186
+ package, max_upload_time = rule.split("<=")
187
+ self._max_upload_time_for[package] = datetime.fromisoformat(
188
+ max_upload_time
189
+ )
190
+ except ValueError:
191
+ raise PackageAgumentError(
192
+ "Malformed argument max_upload_time. Expected <package_name><=<max upload time>"
193
+ )
194
+
195
+ def max_upload_time(self, package: str) -> datetime:
196
+ return self._max_upload_time_for.get(package, self._max_upload_time) or (
197
+ self.current_time - timedelta(days=self.cool_down_phase_days)
198
+ )
199
+
200
+
201
+ class Packages:
202
+ def __init__(self, source: PackageSource, observer: PackageCheckObserver) -> None:
203
+ self.source = source
204
+ self.packages: List[Package] = []
205
+ self.observer: PackageCheckObserver = observer
206
+
207
+ def load(self, package_list: List[Dict[str, str]]) -> List[Package]:
208
+ for package_data in package_list:
209
+ package = Package(
210
+ package_data["name"], package_data["version"], self.source
211
+ )
212
+ self.packages.append(package)
213
+ return self.packages
214
+
215
+ def check_uploads(self, selection: PackageSelection) -> List[Package]:
216
+ recent_packages: List[Package] = []
217
+ for package in self.packages:
218
+ upload_time = package.latest_upload_date
219
+
220
+ if not upload_time:
221
+ self.observer.package_not_found(package)
222
+ continue
223
+
224
+ if upload_time > (
225
+ latest_upload_time := selection.max_upload_time(package.name)
226
+ ):
227
+ self.observer.package_too_recently(
228
+ package, upload_time, latest_upload_time
229
+ )
230
+ recent_packages.append(package)
231
+ return recent_packages
@@ -0,0 +1,274 @@
1
+ import sys
2
+ import os
3
+ import subprocess
4
+ import signal
5
+ import tempfile
6
+ import json
7
+ import shutil
8
+ import time
9
+
10
+ from subprocess import CalledProcessError
11
+ from typing import List, Dict, Any, Optional
12
+ from argparse import ArgumentParser
13
+ from datetime import datetime
14
+
15
+ from .strace_scanner import (
16
+ StraceScanner,
17
+ StraceCredentialsExfiltrationRuleSet,
18
+ ScannerObserver,
19
+ )
20
+
21
+ from .packages import (
22
+ Packages,
23
+ PypiPackageSource,
24
+ PackageCheckObserver,
25
+ Package,
26
+ PackageDownloadError,
27
+ PackageSelection,
28
+ PackageAgumentError,
29
+ )
30
+
31
+
32
+ class InvalidArgumentError(Exception):
33
+ pass
34
+
35
+
36
+ class ScanFailedError(Exception):
37
+ def __init__(self, rc: int, message: str) -> None:
38
+ super().__init__(message)
39
+ self.rc = rc
40
+
41
+
42
+ class UploadVerificationFailedError(Exception):
43
+ pass
44
+
45
+
46
+ class SuspiciousAccessDetected(Exception):
47
+ pass
48
+
49
+
50
+ class AlertingScannerObserver(ScannerObserver):
51
+
52
+ def resource_identified(self, resource: str):
53
+ print("Scanning package install: %s..." % resource)
54
+
55
+ def match_detected(self, resource: str, pattern: str):
56
+ raise SuspiciousAccessDetected(
57
+ "Found suspicious access to %s in package %s" % (pattern, resource),
58
+ )
59
+
60
+
61
+ class PrintingPackageCheckObserver(PackageCheckObserver):
62
+
63
+ def package_not_found(self, package: Package):
64
+ print(f"Package {package.name} not found on pypi")
65
+
66
+ def package_too_recently(
67
+ self, package: Package, upload_time: datetime, latest_upload_time: datetime
68
+ ):
69
+ print(
70
+ f"""Package {package.name} {package.version} was updated too recently: {upload_time.isoformat()}.
71
+ It might be safer to use an older version."""
72
+ )
73
+ upload = package.latest_possible_upload(latest_upload_time)
74
+ if upload:
75
+ print(
76
+ f"""Consider {package.name}<={upload.version} or earlier and check for potential known vulnerabilities of this version.
77
+ If you are certain that the latest upload is safe, add the following argument...
78
+ --allow-upload-time='{package.name}<={upload_time.isoformat()}'
79
+ """
80
+ )
81
+ else:
82
+ print("No suitable version uploaded yet.")
83
+
84
+
85
+ INSTALL_SCRIPT = os.path.join(os.path.dirname(__file__), "sbpip_scan.sh")
86
+ # INSTALL_SCRIPT = os.path.join(os.path.dirname(__file__), 'sbpip_test.sh')
87
+
88
+ parser = ArgumentParser(
89
+ prog="PipCanary", description="Detects supply chain attacks in python dependencies"
90
+ )
91
+
92
+ parser.add_argument("-r", "--requirement")
93
+ parser.add_argument(
94
+ "--max-upload-time",
95
+ help=(
96
+ "Maximum upload time for all packages (ISO 8601 date and time format). "
97
+ "Example: --max-upload-time='2026-04-07T07:43:51+0000'"
98
+ ),
99
+ )
100
+ parser.add_argument(
101
+ "-c",
102
+ "--cool-down-phase-days",
103
+ help=("Cool down phase for packages in days for new package uploads. Default: 7"),
104
+ )
105
+ parser.add_argument(
106
+ "-a",
107
+ "--allow-upload-time",
108
+ action="append",
109
+ help=(
110
+ "Maximum upload time for a single package (ISO 8601 date and time format). "
111
+ "Example: --allow-upload-time='requests<=2026-04-07T07:43:51+0000"
112
+ ),
113
+ )
114
+
115
+ parser.add_argument(
116
+ "-d",
117
+ "--additional-directory",
118
+ help=(
119
+ "Additional directory mapped into the sandbox while scanning"
120
+ "Make sure this directory does not contain sensitive information!"
121
+ ),
122
+ )
123
+
124
+ parser.add_argument(
125
+ "-t",
126
+ "--trace-file",
127
+ help=("The trace file for further analysis"),
128
+ )
129
+
130
+
131
+ def scan_packages(
132
+ requirement_file: str,
133
+ additional_directory: Optional[str],
134
+ trace_file: Optional[str],
135
+ ) -> List[Dict[str, Any]]:
136
+ home_directory = os.environ["HOME"]
137
+
138
+ env = {
139
+ "REQUIREMENTS_FILE": requirement_file,
140
+ **dict(os.environ),
141
+ }
142
+
143
+ if additional_directory:
144
+ env["PIPCANARY_ADDITIONAL_DIRECTORY"] = os.path.abspath(additional_directory)
145
+
146
+ command = ["sh", INSTALL_SCRIPT]
147
+
148
+ venv_directory = tempfile.mkdtemp(suffix="-pipcanary")
149
+ process = None
150
+
151
+ try:
152
+ env["PIPCANARY_VIRTUAL_ENV"] = venv_directory
153
+
154
+ observer = AlertingScannerObserver()
155
+ scanner = StraceScanner(
156
+ StraceCredentialsExfiltrationRuleSet(home_directory, venv_directory),
157
+ observer,
158
+ trace_file,
159
+ )
160
+
161
+ process = subprocess.Popen(command, stderr=subprocess.PIPE, text=True, env=env)
162
+
163
+ assert process.stderr
164
+
165
+ scanner.scan(process.stderr)
166
+
167
+ process.wait()
168
+
169
+ if process.returncode != 0:
170
+ raise ScanFailedError(
171
+ process.returncode, "Scan failed with rc %d" % process.returncode
172
+ )
173
+ with open(os.path.join(venv_directory, "packages.json"), "r") as f:
174
+ return json.load(f)
175
+ except SuspiciousAccessDetected as e:
176
+ if process:
177
+ os.kill(process.pid, signal.SIGKILL)
178
+ raise e
179
+ finally:
180
+ for i in range(3):
181
+ try:
182
+ shutil.rmtree(venv_directory)
183
+ except OSError:
184
+ time.sleep(i)
185
+
186
+
187
+ def check_package_uploads(
188
+ package_list: List[Dict[str, Any]], selection: PackageSelection
189
+ ):
190
+ print("Checking the most recent package uploads...\n")
191
+ packages = Packages(PypiPackageSource(), PrintingPackageCheckObserver())
192
+ packages.load(package_list)
193
+ recent_packages = packages.check_uploads(selection)
194
+
195
+ if recent_packages:
196
+ raise UploadVerificationFailedError(
197
+ "The following packages were uploaded too recently: %s"
198
+ % (", ".join([p.name for p in recent_packages]))
199
+ )
200
+
201
+
202
+ def check_command(command: str, test_command: List[str]):
203
+ try:
204
+ subprocess.check_call(test_command)
205
+ except CalledProcessError:
206
+ print("Required command %s not found!" % command)
207
+ exit(-1)
208
+
209
+
210
+ def check_package(package: str, test_command: List[str]):
211
+ try:
212
+ subprocess.check_call(test_command)
213
+ except CalledProcessError:
214
+ print("Required python package %s not found!" % package)
215
+ exit(-1)
216
+
217
+
218
+ def pipcanary():
219
+ args = parser.parse_args()
220
+ check_package("venv", ["sh", "-c", "python3 -m venv --help 1>/dev/null"])
221
+ check_command("strace", ["sh", "-c", "strace -V 1>/dev/null"])
222
+ check_command("bwrap", ["sh", "-c", "bwrap --version 1>/dev/null"])
223
+
224
+ try:
225
+ requirement_file = args.requirement
226
+ trace_file = args.trace_file
227
+ selection = PackageSelection(
228
+ args.max_upload_time, args.cool_down_phase_days, args.allow_upload_time
229
+ )
230
+ additional_directory = args.additional_directory
231
+
232
+ if not requirement_file:
233
+ requirement_file = os.path.join(os.path.curdir, "requirements.txt")
234
+
235
+ if not os.path.exists(requirement_file):
236
+ raise InvalidArgumentError(
237
+ "Requirements file %s does not exist" % requirement_file
238
+ )
239
+
240
+ packages = scan_packages(requirement_file, additional_directory, trace_file)
241
+ check_package_uploads(packages, selection)
242
+ print("All packages appear to be safe!")
243
+ except (InvalidArgumentError, PackageAgumentError) as e:
244
+ print(str(e), file=sys.stderr)
245
+ exit(1)
246
+ except ScanFailedError as e:
247
+ print(str(e), file=sys.stderr)
248
+ exit(2)
249
+ except PackageDownloadError as e:
250
+ print(
251
+ "Failed to download package information for %s: %s"
252
+ % (e.package_name, str(e)),
253
+ file=sys.stderr,
254
+ )
255
+ exit(3)
256
+ except UploadVerificationFailedError as e:
257
+ print(str(e), file=sys.stderr)
258
+ exit(4)
259
+ except SuspiciousAccessDetected as e:
260
+ msg = f"""
261
+ {str(e)}.
262
+ This could be dangerous!!!
263
+ Don't install this package under any circumstances until you know for sure that this is a false positive!
264
+ In doubt, contact the package maintainers!
265
+ """
266
+ print(msg, file=sys.stderr)
267
+ exit(5)
268
+
269
+ except KeyboardInterrupt:
270
+ print("PipCanary was interrupted.")
271
+
272
+
273
+ if __name__ == "__main__":
274
+ pipcanary()
@@ -0,0 +1,84 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ REQUIREMENTS_FILE=${REQUIREMENTS_FILE:-requirements.txt}
5
+ PYTHON3=`which python`
6
+
7
+ PYTHON_BIN="$(dirname $PYTHON3)"
8
+ PYTHON_DIR="$(dirname $PYTHON_BIN)"
9
+
10
+ echo "Unsing python directory $PYTHON_DIR..."
11
+
12
+ VIRTUAL_ENV=${PIPCANARY_VIRTUAL_ENV:-`mktemp -d --suffix=-pipcanary`}
13
+
14
+ ADDITIONAL_DIRECTORY=${PIPCANARY_ADDITIONAL_DIRECTORY:-/usr}
15
+
16
+ echo "Creating virtual environment in $VIRTUAL_ENV..."
17
+
18
+ mkdir -p $VIRTUAL_ENV
19
+ rm -rf $VIRTUAL_ENV/*
20
+ $PYTHON3 -m venv $VIRTUAL_ENV
21
+
22
+ echo "Installing $REQUIREMENTS_FILE..."
23
+ cp -v $REQUIREMENTS_FILE $VIRTUAL_ENV/requirements.txt
24
+
25
+ MODULE_LOADER_SOURCE=$(dirname "$0")/module_loader.py
26
+ MODULE_LOADER=$VIRTUAL_ENV/module_loader.py
27
+
28
+ SITE_PACKAGES=lib/python3.10/site-packages/
29
+
30
+ cp -v $MODULE_LOADER_SOURCE $MODULE_LOADER
31
+
32
+ # Wait for scanner
33
+ sleep 0.5
34
+
35
+ # Installing packages with network
36
+ strace -f -e trace=file \
37
+ bwrap \
38
+ --unshare-user \
39
+ --share-net \
40
+ --die-with-parent \
41
+ --uid 0 --gid 0 \
42
+ --new-session \
43
+ --ro-bind $PYTHON_DIR $PYTHON_DIR \
44
+ --bind $VIRTUAL_ENV $VIRTUAL_ENV \
45
+ --proc /proc \
46
+ --dev /dev \
47
+ --ro-bind /usr /usr \
48
+ --ro-bind /lib /lib \
49
+ --ro-bind /lib64 /lib64 \
50
+ --ro-bind /etc/ssl/certs/ /etc/ssl/certs/ \
51
+ --ro-bind /etc/resolv.conf /etc/resolv.conf \
52
+ --ro-bind $ADDITIONAL_DIRECTORY $ADDITIONAL_DIRECTORY \
53
+ --clearenv \
54
+ --setenv HOME "/root" \
55
+ --setenv PATH "$VIRTUAL_ENV/bin:/usr/bin" \
56
+ --chdir $VIRTUAL_ENV \
57
+ pip install --no-cache -r requirements.txt
58
+
59
+ # Loading modules without network and further file access restrictions
60
+ strace -f -e trace=file \
61
+ bwrap \
62
+ --unshare-user \
63
+ --die-with-parent \
64
+ --uid 0 --gid 0 \
65
+ --new-session \
66
+ --ro-bind $PYTHON_DIR $PYTHON_DIR \
67
+ --bind $VIRTUAL_ENV $VIRTUAL_ENV \
68
+ --proc /proc \
69
+ --dev /dev \
70
+ --ro-bind /usr /usr \
71
+ --ro-bind /lib /lib \
72
+ --ro-bind /lib64 /lib64 \
73
+ --clearenv \
74
+ --setenv HOME "/root" \
75
+ --setenv PATH "$VIRTUAL_ENV/bin:/usr/bin" \
76
+ --chdir $VIRTUAL_ENV \
77
+ sh -c "python $MODULE_LOADER; pip list --format=json > packages.json"
78
+
79
+ if [ -z "${PIPCANARY_VIRTUAL_ENV+x}" ]; then
80
+ echo "Removing $VIRTUAL_ENV..."
81
+ rm -r $VIRTUAL_ENV
82
+ fi
83
+
84
+ echo "Done."
@@ -0,0 +1,125 @@
1
+ import sys
2
+ import re
3
+ import io
4
+
5
+ from typing import Iterator, Optional, List
6
+ from abc import ABC, abstractmethod
7
+ from re import Pattern
8
+
9
+
10
+ class RuleSet(ABC):
11
+ @abstractmethod
12
+ def identify_resource(self, line: str) -> Optional[str]:
13
+ pass
14
+
15
+ @abstractmethod
16
+ def match(self, line: str) -> Optional[str]:
17
+ pass
18
+
19
+ @abstractmethod
20
+ def warnings_or_errors(self, line: str) -> Optional[str]:
21
+ pass
22
+
23
+
24
+ class StraceCredentialsExfiltrationRuleSet(RuleSet):
25
+
26
+ @staticmethod
27
+ def compile_package_rule(venv_directory: str) -> Pattern:
28
+ return re.compile(
29
+ r"^\[pid [0-9]+\] mkdir\(\"%s/lib/python.*/site-packages/([a-zA-Z][a-zA-Z0-9_-]+)\", 0777\) = 0"
30
+ % re.escape(venv_directory)
31
+ )
32
+
33
+ @staticmethod
34
+ def path_access(path: str) -> Pattern:
35
+ return re.compile(
36
+ r"^\[pid [0-9]+\] (statx|openat|access)\(AT_FDCWD, \"(%s).*$" % path
37
+ )
38
+
39
+ @classmethod
40
+ def compile_rules(cls, home_directory: str) -> List[Pattern]:
41
+ home_directories = ["/root", home_directory]
42
+ relative_pathes = [
43
+ "/.ssh",
44
+ "/.ssh/id_rsa",
45
+ "/.ssh/id_ed25519",
46
+ "/.ssh/id_ecdsa",
47
+ "/.ssh/id_dsa",
48
+ "/.ssh/authorized_keys",
49
+ "/.ssh/known_hosts",
50
+ "/.ssh/config",
51
+ "/.git-credentials",
52
+ "/.gitconfig",
53
+ "/.aws",
54
+ "/.aws/credentials",
55
+ "/.aws/config",
56
+ ]
57
+
58
+ rules = []
59
+ for directory in home_directories:
60
+ for relative_path in relative_pathes:
61
+ rules.append(cls.path_access(directory + relative_path))
62
+
63
+ return rules
64
+
65
+ def __init__(self, home_directory: str, venv_directory: str) -> None:
66
+ self.package_rule = self.compile_package_rule(venv_directory)
67
+ self.rules = self.compile_rules(home_directory)
68
+
69
+ def identify_resource(self, line: str) -> Optional[str]:
70
+ if line.startswith("Package: "):
71
+ return line[9:]
72
+
73
+ if match := self.package_rule.match(line):
74
+ return match.groups()[0]
75
+
76
+ def match(self, line: str) -> Optional[str]:
77
+ for rule in self.rules:
78
+ if match := rule.match(line):
79
+ return match.groups()[1]
80
+
81
+ def warnings_or_errors(self, line: str) -> Optional[str]:
82
+ if line.startswith("WARNING: ") or line.startswith("ERROR: "):
83
+ return line
84
+
85
+
86
+ class ScannerObserver(ABC):
87
+ @abstractmethod
88
+ def resource_identified(self, resource: str):
89
+ pass
90
+
91
+ @abstractmethod
92
+ def match_detected(self, resource: str, pattern: str):
93
+ pass
94
+
95
+
96
+ class StraceScanner:
97
+ def __init__(
98
+ self, rule_set: RuleSet, observer: ScannerObserver, trace_file: Optional[str]
99
+ ) -> None:
100
+ self.rule_set = rule_set
101
+ self.observer = observer
102
+ self.trace_file = trace_file
103
+ self.resource = ""
104
+
105
+ def _scan_lines(self, lines: Iterator, fp: Optional[io.TextIOBase] = None):
106
+ for line in lines:
107
+ if fp:
108
+ fp.write(line)
109
+
110
+ if msg := self.rule_set.warnings_or_errors(line):
111
+ print(msg, file=sys.stderr)
112
+
113
+ if resource := self.rule_set.identify_resource(line):
114
+ self.resource = resource
115
+ continue
116
+
117
+ if match := self.rule_set.match(line):
118
+ self.observer.match_detected(self.resource, match)
119
+
120
+ def scan(self, lines: Iterator):
121
+ if self.trace_file:
122
+ with open(self.trace_file, "w") as fp:
123
+ self._scan_lines(lines, fp)
124
+ else:
125
+ self._scan_lines(lines)
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipcanary
3
+ Version: 0.0.3
4
+ Summary: Supply Chain Attack prevention tool
5
+ Author-email: Sebastian Kuebeck <sebastian.kuebeck@encab.io>
6
+ Project-URL: Homepage, https://github.com/sebastian-kuebeck/pipcanary
7
+ Project-URL: Documentation, https://github.com/sebastian-kuebeck/pipcanary
8
+ Project-URL: Bug Tracker, https://github.com/sebastian-kuebeck/pipcanary
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Programming Language :: Python :: 3.15
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: virtualenv~=21.2.0
21
+ Dynamic: license-file
22
+
23
+ # PipCanary
24
+
25
+ ## Features
26
+
27
+ - Detects Supply Chain Attacks in Python packages
28
+
29
+ - Makes sure that new package releases are only installed after a cool down period, so secuity scanners have time to detect vulnerabilities
30
+
31
+ ## Maturity
32
+
33
+ The project is in early stages. However, it's safer to use this than pip, poetry or uv alone.
34
+
35
+ ## Requirements
36
+
37
+ - Linux
38
+
39
+ - [Python](https://www.python.org/) 3.10 or higher
40
+
41
+ - [bubblewrap](https://github.com/containers/bubblewrap)
42
+
43
+ - [strace](https://strace.io)
44
+
45
+ - [pip](https://pip.pypa.io/en/stable/getting-started/)
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install pipcanary
51
+ ```
52
+
53
+ ## Execution
54
+
55
+ Check your requirements for potential Supply Chain Attacks
56
+
57
+ ```bash
58
+ pipcanary -r requirements.txt
59
+ ```
60
+
61
+ Sample output when all is fine...
62
+
63
+ ```text
64
+ ...
65
+ All packages appear to be safe!
66
+ ```
67
+
68
+ Sample output if a potential attack is detected...
69
+
70
+ ```text
71
+ ...
72
+ Found suspicious access to /root/.ssh in package evilpack
73
+
74
+ This could be dangerous!!!
75
+ Don't install this package under any circumstances until you know for sure that this is a false positive!
76
+ In doubt, contact the package maintainers!
77
+ ```
78
+
79
+ Sample output when packages were updated during the cooling of phase of one week...
80
+
81
+ ```text
82
+ ...
83
+ Package click 8.3.2 was updated too recently: 2026-04-03T19:14:45.
84
+ It might be safer to use an older version.
85
+ Consider click<=8.3.1 or earlier and check for potential known vulnerabilities of this version.
86
+ If you are certain that the latest upload is safe, add the following argument...
87
+ --allow-upload-time='click<=2026-04-03T19:14:45'
88
+
89
+ Package Werkzeug 3.1.8 was updated too recently: 2026-04-02T18:49:14.
90
+ It might be safer to use an older version.
91
+ Consider Werkzeug<=3.1.7 or earlier and check for potential known vulnerabilities of this version.
92
+ If you are certain that the latest upload is safe, add the following argument...
93
+ --allow-upload-time='Werkzeug<=2026-04-02T18:49:14'
94
+
95
+ The following packages were uploaded too recently: click, Werkzeug
96
+ ```
97
+
98
+ ## Similar Projects
99
+
100
+ - [pip-audit](https://github.com/pypa/pip-audit)
101
+
102
+ ## Further Information on PyPi Suppy Chaion Attacks
103
+
104
+ - [How a Poisoned Security Scanner Became the Key to Backdooring LiteLLM](https://snyk.io/de/articles/poisoned-security-scanner-backdooring-litellm/)
105
+ - [The Team PCP Snowball Effect: A Quantitative Analysis](https://blog.gitguardian.com/team-pcp-snowball-analysis/)
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ src/pipcanary/__init__.py
6
+ src/pipcanary/__main__.py
7
+ src/pipcanary/module_loader.py
8
+ src/pipcanary/packages.py
9
+ src/pipcanary/pipcanary.py
10
+ src/pipcanary/sbpip_scan.sh
11
+ src/pipcanary/strace_scanner.py
12
+ src/pipcanary.egg-info/PKG-INFO
13
+ src/pipcanary.egg-info/SOURCES.txt
14
+ src/pipcanary.egg-info/dependency_links.txt
15
+ src/pipcanary.egg-info/entry_points.txt
16
+ src/pipcanary.egg-info/requires.txt
17
+ src/pipcanary.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pipcanary = pipcanary.pipcanary:pipcanary
@@ -0,0 +1 @@
1
+ virtualenv~=21.2.0
@@ -0,0 +1 @@
1
+ pipcanary