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.
- pipcanary-0.0.3/LICENSE +21 -0
- pipcanary-0.0.3/MANIFEST.in +1 -0
- pipcanary-0.0.3/PKG-INFO +105 -0
- pipcanary-0.0.3/README.md +83 -0
- pipcanary-0.0.3/pyproject.toml +40 -0
- pipcanary-0.0.3/setup.cfg +4 -0
- pipcanary-0.0.3/src/pipcanary/__init__.py +0 -0
- pipcanary-0.0.3/src/pipcanary/__main__.py +4 -0
- pipcanary-0.0.3/src/pipcanary/module_loader.py +22 -0
- pipcanary-0.0.3/src/pipcanary/packages.py +231 -0
- pipcanary-0.0.3/src/pipcanary/pipcanary.py +274 -0
- pipcanary-0.0.3/src/pipcanary/sbpip_scan.sh +84 -0
- pipcanary-0.0.3/src/pipcanary/strace_scanner.py +125 -0
- pipcanary-0.0.3/src/pipcanary.egg-info/PKG-INFO +105 -0
- pipcanary-0.0.3/src/pipcanary.egg-info/SOURCES.txt +17 -0
- pipcanary-0.0.3/src/pipcanary.egg-info/dependency_links.txt +1 -0
- pipcanary-0.0.3/src/pipcanary.egg-info/entry_points.txt +2 -0
- pipcanary-0.0.3/src/pipcanary.egg-info/requires.txt +1 -0
- pipcanary-0.0.3/src/pipcanary.egg-info/top_level.txt +1 -0
pipcanary-0.0.3/LICENSE
ADDED
|
@@ -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
|
pipcanary-0.0.3/PKG-INFO
ADDED
|
@@ -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
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
virtualenv~=21.2.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pipcanary
|