pysealer 0.6.0__pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
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.
- pysealer/__init__.py +24 -0
- pysealer/_pysealer.pypy311-pp73-aarch64-linux-gnu.so +0 -0
- pysealer/add_decorators.py +228 -0
- pysealer/check_decorators.py +186 -0
- pysealer/cli.py +367 -0
- pysealer/dummy_decorators.py +83 -0
- pysealer/git_diff.py +228 -0
- pysealer/github_secrets.py +175 -0
- pysealer/remove_decorators.py +88 -0
- pysealer/setup.py +137 -0
- pysealer-0.6.0.dist-info/METADATA +171 -0
- pysealer-0.6.0.dist-info/RECORD +15 -0
- pysealer-0.6.0.dist-info/WHEEL +5 -0
- pysealer-0.6.0.dist-info/entry_points.txt +2 -0
- pysealer-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub secrets integration for pysealer.
|
|
3
|
+
|
|
4
|
+
This module provides functionality to automatically upload the pysealer public key
|
|
5
|
+
to GitHub repository secrets when `pysealer init` is run.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Tuple
|
|
13
|
+
|
|
14
|
+
import git
|
|
15
|
+
from github import Github, GithubException
|
|
16
|
+
from nacl import encoding, public
|
|
17
|
+
|
|
18
|
+
# Suppress verbose GitHub API logging
|
|
19
|
+
logging.getLogger("github").setLevel(logging.CRITICAL)
|
|
20
|
+
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_repo_info() -> Tuple[str, str]:
|
|
24
|
+
"""
|
|
25
|
+
Extract GitHub owner and repository name from git remote URL.
|
|
26
|
+
|
|
27
|
+
Supports both SSH and HTTPS formats:
|
|
28
|
+
- SSH: git@github.com:owner/repo.git
|
|
29
|
+
- HTTPS: https://github.com/owner/repo.git
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Tuple[str, str]: (owner, repo_name)
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If not in a git repository or remote URL is not from GitHub
|
|
36
|
+
RuntimeError: If unable to parse the remote URL
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# Get the git repository from current directory
|
|
40
|
+
repo = git.Repo(search_parent_directories=True)
|
|
41
|
+
|
|
42
|
+
# Try to get the origin remote
|
|
43
|
+
if 'origin' not in repo.remotes:
|
|
44
|
+
raise ValueError("No 'origin' remote found in git repository")
|
|
45
|
+
|
|
46
|
+
remote_url = repo.remotes.origin.url
|
|
47
|
+
|
|
48
|
+
# Parse SSH format: git@github.com:owner/repo.git
|
|
49
|
+
ssh_pattern = r'git@github\.com:([^/]+)/(.+?)(?:\.git)?$'
|
|
50
|
+
ssh_match = re.match(ssh_pattern, remote_url)
|
|
51
|
+
if ssh_match:
|
|
52
|
+
owner, repo_name = ssh_match.groups()
|
|
53
|
+
return owner, repo_name
|
|
54
|
+
|
|
55
|
+
# Parse HTTPS format: https://github.com/owner/repo.git
|
|
56
|
+
https_pattern = r'https://github\.com/([^/]+)/(.+?)(?:\.git)?$'
|
|
57
|
+
https_match = re.match(https_pattern, remote_url)
|
|
58
|
+
if https_match:
|
|
59
|
+
owner, repo_name = https_match.groups()
|
|
60
|
+
return owner, repo_name
|
|
61
|
+
|
|
62
|
+
raise RuntimeError(f"Could not parse GitHub repository from remote URL: {remote_url}")
|
|
63
|
+
|
|
64
|
+
except git.InvalidGitRepositoryError:
|
|
65
|
+
raise ValueError("Not in a git repository. Please run this command from within a git repository.")
|
|
66
|
+
except git.GitCommandError as e:
|
|
67
|
+
raise RuntimeError(f"Git error: {e}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def encrypt_secret(public_key: str, secret_value: str) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Encrypt a secret using GitHub's public key.
|
|
73
|
+
|
|
74
|
+
GitHub requires secrets to be encrypted using the repository's public key
|
|
75
|
+
before they can be uploaded via the API.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
public_key: The repository's public key (base64 encoded)
|
|
79
|
+
secret_value: The secret value to encrypt
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
str: Base64 encoded encrypted secret
|
|
83
|
+
"""
|
|
84
|
+
# Convert the public key from base64
|
|
85
|
+
public_key_bytes = public_key.encode("utf-8")
|
|
86
|
+
public_key_obj = public.PublicKey(public_key_bytes, encoding.Base64Encoder())
|
|
87
|
+
|
|
88
|
+
# Encrypt the secret using sealed box
|
|
89
|
+
sealed_box = public.SealedBox(public_key_obj)
|
|
90
|
+
encrypted = sealed_box.encrypt(secret_value.encode("utf-8"))
|
|
91
|
+
|
|
92
|
+
# Return base64 encoded encrypted secret
|
|
93
|
+
return encoding.Base64Encoder().encode(encrypted).decode("utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def add_secret_to_github(token: str, owner: str, repo_name: str, secret_name: str, secret_value: str) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Add or update a secret in GitHub repository.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
token: GitHub personal access token (needs 'repo' scope)
|
|
102
|
+
owner: GitHub repository owner (user or organization)
|
|
103
|
+
repo_name: Repository name
|
|
104
|
+
secret_name: Name of the secret to create/update
|
|
105
|
+
secret_value: Value of the secret (will be encrypted before upload)
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
GithubException: If GitHub API request fails
|
|
109
|
+
Exception: For other errors during the process
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
# Initialize GitHub client
|
|
113
|
+
g = Github(token)
|
|
114
|
+
|
|
115
|
+
# Get the repository
|
|
116
|
+
repo = g.get_repo(f"{owner}/{repo_name}")
|
|
117
|
+
|
|
118
|
+
# Get the repository's public key for encrypting secrets
|
|
119
|
+
public_key = repo.get_public_key()
|
|
120
|
+
|
|
121
|
+
# Encrypt the secret
|
|
122
|
+
encrypted_value = encrypt_secret(public_key.key, secret_value)
|
|
123
|
+
|
|
124
|
+
# Create or update the secret (actions secrets are at repository level)
|
|
125
|
+
repo.create_secret(secret_name, encrypted_value, secret_type="actions")
|
|
126
|
+
|
|
127
|
+
except GithubException as e:
|
|
128
|
+
if e.status == 401:
|
|
129
|
+
raise Exception("Authentication failed. Please check your GitHub token.")
|
|
130
|
+
elif e.status == 403:
|
|
131
|
+
raise Exception("Permission denied. Your token needs 'repo' scope to manage secrets.")
|
|
132
|
+
elif e.status == 404:
|
|
133
|
+
raise Exception(f"Repository '{owner}/{repo_name}' not found or you don't have access.")
|
|
134
|
+
else:
|
|
135
|
+
raise Exception(f"GitHub API error: {e.data.get('message', str(e))}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def setup_github_secrets(public_key: str, github_token: Optional[str] = None) -> Tuple[bool, str]:
|
|
139
|
+
"""
|
|
140
|
+
Main function to orchestrate GitHub secrets setup.
|
|
141
|
+
|
|
142
|
+
This function:
|
|
143
|
+
1. Gets GitHub token from parameter or environment variable
|
|
144
|
+
2. Extracts repository info from git remote
|
|
145
|
+
3. Uploads the public key to GitHub secrets
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
public_key: The pysealer public key to upload
|
|
149
|
+
github_token: Optional GitHub token. If None, uses GITHUB_TOKEN env var
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Tuple[bool, str]: (success, message)
|
|
153
|
+
- success: True if secret was uploaded successfully
|
|
154
|
+
- message: Success or error message
|
|
155
|
+
"""
|
|
156
|
+
# Get GitHub token
|
|
157
|
+
token = github_token or os.getenv("GITHUB_TOKEN")
|
|
158
|
+
if not token:
|
|
159
|
+
return False, "No GitHub token provided. Use --github-token or set GITHUB_TOKEN environment variable."
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
# Get repository information
|
|
163
|
+
owner, repo_name = get_repo_info()
|
|
164
|
+
|
|
165
|
+
# Upload the secret
|
|
166
|
+
add_secret_to_github(token, owner, repo_name, "PYSEALER_PUBLIC_KEY", public_key)
|
|
167
|
+
|
|
168
|
+
return True, f"Successfully added PYSEALER_PUBLIC_KEY to {owner}/{repo_name}"
|
|
169
|
+
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
return False, f"Repository detection failed: {e}"
|
|
172
|
+
except RuntimeError as e:
|
|
173
|
+
return False, f"Git error: {e}"
|
|
174
|
+
except Exception as e:
|
|
175
|
+
return False, f"Failed to upload secret: {e}"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Remove cryptographic pysealer decorators from all functions and classes in a Python file."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import List, Tuple, Dict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
def remove_decorators(file_path: str) -> Tuple[str, bool]:
|
|
8
|
+
"""
|
|
9
|
+
Parse a Python file, remove all @pysealer.* decorators from functions and classes, and return the modified code.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
file_path: Path to the Python file to process
|
|
13
|
+
Returns:
|
|
14
|
+
Modified Python source code as a string
|
|
15
|
+
"""
|
|
16
|
+
with open(file_path, 'r') as f:
|
|
17
|
+
content = f.read()
|
|
18
|
+
|
|
19
|
+
tree = ast.parse(content)
|
|
20
|
+
lines = content.split('\n')
|
|
21
|
+
lines_to_remove = set()
|
|
22
|
+
|
|
23
|
+
for node in ast.walk(tree):
|
|
24
|
+
if type(node).__name__ in ("FunctionDef", "AsyncFunctionDef", "ClassDef"):
|
|
25
|
+
if hasattr(node, 'decorator_list'):
|
|
26
|
+
for decorator in node.decorator_list:
|
|
27
|
+
is_pysealer_decorator = False
|
|
28
|
+
if isinstance(decorator, ast.Name):
|
|
29
|
+
if decorator.id.startswith("pysealer"):
|
|
30
|
+
is_pysealer_decorator = True
|
|
31
|
+
elif isinstance(decorator, ast.Attribute):
|
|
32
|
+
if isinstance(decorator.value, ast.Name) and decorator.value.id == "pysealer":
|
|
33
|
+
is_pysealer_decorator = True
|
|
34
|
+
elif isinstance(decorator, ast.Call):
|
|
35
|
+
func = decorator.func
|
|
36
|
+
if isinstance(func, ast.Attribute):
|
|
37
|
+
if isinstance(func.value, ast.Name) and func.value.id == "pysealer":
|
|
38
|
+
is_pysealer_decorator = True
|
|
39
|
+
elif isinstance(func, ast.Name) and func.id.startswith("pysealer"):
|
|
40
|
+
is_pysealer_decorator = True
|
|
41
|
+
if is_pysealer_decorator:
|
|
42
|
+
lines_to_remove.add(decorator.lineno - 1)
|
|
43
|
+
|
|
44
|
+
found = len(lines_to_remove) > 0
|
|
45
|
+
for line_idx in sorted(lines_to_remove, reverse=True):
|
|
46
|
+
del lines[line_idx]
|
|
47
|
+
|
|
48
|
+
modified_code = '\n'.join(lines)
|
|
49
|
+
return modified_code, found
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def remove_decorators_from_folder(folder_path: str) -> List[str]:
|
|
53
|
+
"""
|
|
54
|
+
Remove pysealer decorators from all Python files in a folder (recursively).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
folder_path: Path to the folder to process
|
|
58
|
+
Returns:
|
|
59
|
+
List of file paths where decorators were removed
|
|
60
|
+
"""
|
|
61
|
+
folder = Path(folder_path)
|
|
62
|
+
|
|
63
|
+
if not folder.is_dir():
|
|
64
|
+
raise NotADirectoryError(f"'{folder_path}' is not a directory")
|
|
65
|
+
|
|
66
|
+
# Find all Python files recursively
|
|
67
|
+
python_files = list(folder.rglob("*.py"))
|
|
68
|
+
|
|
69
|
+
if not python_files:
|
|
70
|
+
raise FileNotFoundError(f"No Python files found in '{folder_path}'")
|
|
71
|
+
|
|
72
|
+
files_modified = []
|
|
73
|
+
|
|
74
|
+
for py_file in python_files:
|
|
75
|
+
try:
|
|
76
|
+
file_path = str(py_file.resolve())
|
|
77
|
+
modified_code, found = remove_decorators(file_path)
|
|
78
|
+
|
|
79
|
+
if found:
|
|
80
|
+
# Write the modified code back to the file
|
|
81
|
+
with open(file_path, 'w') as f:
|
|
82
|
+
f.write(modified_code)
|
|
83
|
+
files_modified.append(file_path)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
# Skip files that can't be processed
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
return files_modified
|
pysealer/setup.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Setup the storage of the pysealer keypair in a .env file."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from dotenv import load_dotenv, set_key
|
|
7
|
+
from pysealer import generate_keypair
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _find_env_file() -> Path:
|
|
11
|
+
"""
|
|
12
|
+
Search for .env file starting from current directory and walking up to parent directories.
|
|
13
|
+
Also checks PYSEALER_ENV_PATH environment variable.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Path: Path to the .env file
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
FileNotFoundError: If no .env file is found
|
|
20
|
+
"""
|
|
21
|
+
# First check if PYSEALER_ENV_PATH environment variable is set
|
|
22
|
+
env_path_var = os.getenv("PYSEALER_ENV_PATH")
|
|
23
|
+
if env_path_var:
|
|
24
|
+
env_path = Path(env_path_var)
|
|
25
|
+
if env_path.exists():
|
|
26
|
+
return env_path
|
|
27
|
+
|
|
28
|
+
# Start from current working directory and search upward
|
|
29
|
+
current = Path.cwd()
|
|
30
|
+
|
|
31
|
+
# Check current directory and all parent directories up to root
|
|
32
|
+
for parent in [current] + list(current.parents):
|
|
33
|
+
env_file = parent / '.env'
|
|
34
|
+
if env_file.exists():
|
|
35
|
+
return env_file
|
|
36
|
+
|
|
37
|
+
# If not found, return the default location (current directory)
|
|
38
|
+
# This will be used in error messages
|
|
39
|
+
return Path.cwd() / '.env'
|
|
40
|
+
|
|
41
|
+
def setup_keypair(env_path: Optional[str | Path] = None):
|
|
42
|
+
"""
|
|
43
|
+
Generate and store keypair securely.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
env_path: Optional path to .env file. If None, creates in current directory.
|
|
47
|
+
"""
|
|
48
|
+
# Determine .env location
|
|
49
|
+
if env_path is None:
|
|
50
|
+
env_path = Path.cwd() / '.env'
|
|
51
|
+
else:
|
|
52
|
+
env_path = Path(env_path)
|
|
53
|
+
|
|
54
|
+
# Check if keys already exist
|
|
55
|
+
if env_path.exists():
|
|
56
|
+
load_dotenv(env_path)
|
|
57
|
+
existing_private = os.getenv("PYSEALER_PRIVATE_KEY")
|
|
58
|
+
existing_public = os.getenv("PYSEALER_PUBLIC_KEY")
|
|
59
|
+
|
|
60
|
+
if existing_private or existing_public:
|
|
61
|
+
raise ValueError(f"Keys already exist in {env_path} Cannot overwrite existing keys.")
|
|
62
|
+
|
|
63
|
+
# Create .env if it doesn't exist
|
|
64
|
+
env_path.touch(exist_ok=True)
|
|
65
|
+
|
|
66
|
+
# Generate keypair using the Rust function
|
|
67
|
+
private_key_hex, public_key_hex = generate_keypair()
|
|
68
|
+
|
|
69
|
+
# Store keys in .env file
|
|
70
|
+
set_key(str(env_path), "PYSEALER_PRIVATE_KEY", private_key_hex)
|
|
71
|
+
set_key(str(env_path), "PYSEALER_PUBLIC_KEY", public_key_hex)
|
|
72
|
+
|
|
73
|
+
return private_key_hex, public_key_hex
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_public_key(env_path: Optional[str | Path] = None) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Retrieve the public key from the .env file.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
env_path: Optional path to .env file. If None, searches from current directory upward.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
str: The public key hex string, or None if not found.
|
|
85
|
+
"""
|
|
86
|
+
# Determine .env location
|
|
87
|
+
if env_path is None:
|
|
88
|
+
env_path = _find_env_file()
|
|
89
|
+
else:
|
|
90
|
+
env_path = Path(env_path)
|
|
91
|
+
|
|
92
|
+
# Check if .env exists
|
|
93
|
+
if not env_path.exists():
|
|
94
|
+
raise FileNotFoundError(f"No .env file found at {env_path}. Run setup_keypair() first.")
|
|
95
|
+
|
|
96
|
+
# Load environment variables from .env
|
|
97
|
+
load_dotenv(env_path)
|
|
98
|
+
|
|
99
|
+
# Get public key
|
|
100
|
+
public_key = os.getenv("PYSEALER_PUBLIC_KEY")
|
|
101
|
+
|
|
102
|
+
if public_key is None:
|
|
103
|
+
raise ValueError(f"PYSEALER_PUBLIC_KEY not found in {env_path}. Run setup_keypair() first.")
|
|
104
|
+
|
|
105
|
+
return public_key
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_private_key(env_path: Optional[str | Path] = None) -> str:
|
|
109
|
+
"""
|
|
110
|
+
Retrieve the private key from the .env file.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
env_path: Optional path to .env file. If None, searches from current directory upward.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
str: The private key hex string, or None if not found.
|
|
117
|
+
"""
|
|
118
|
+
# Determine .env location
|
|
119
|
+
if env_path is None:
|
|
120
|
+
env_path = _find_env_file()
|
|
121
|
+
else:
|
|
122
|
+
env_path = Path(env_path)
|
|
123
|
+
|
|
124
|
+
# Check if .env exists
|
|
125
|
+
if not env_path.exists():
|
|
126
|
+
raise FileNotFoundError(f"No .env file found at {env_path}. Run setup_keypair() first.")
|
|
127
|
+
|
|
128
|
+
# Load environment variables from .env
|
|
129
|
+
load_dotenv(env_path)
|
|
130
|
+
|
|
131
|
+
# Get private key
|
|
132
|
+
private_key = os.getenv("PYSEALER_PRIVATE_KEY")
|
|
133
|
+
|
|
134
|
+
if private_key is None:
|
|
135
|
+
raise ValueError(f"PYSEALER_PRIVATE_KEY not found in {env_path}. Run setup_keypair() first.")
|
|
136
|
+
|
|
137
|
+
return private_key
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysealer
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Classifier: Development Status :: 3 - Alpha
|
|
5
|
+
Classifier: Intended Audience :: Developers
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Rust
|
|
14
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
15
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
19
|
+
Requires-Dist: typer>=0.9.0
|
|
20
|
+
Requires-Dist: pygithub>=2.1.1
|
|
21
|
+
Requires-Dist: pynacl>=1.5.0
|
|
22
|
+
Requires-Dist: gitpython>=3.1.0
|
|
23
|
+
Requires-Dist: pytest>=7.0.0 ; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest-cov>=4.0.0 ; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21.0 ; extra == 'test'
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Summary: Cryptographically sign Python functions and classes for defense-in-depth security
|
|
29
|
+
Keywords: rust,python,decorator,cryptography
|
|
30
|
+
Author: Aidan Dyga
|
|
31
|
+
License: MIT
|
|
32
|
+
Requires-Python: >=3.8
|
|
33
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
34
|
+
Project-URL: Issues, https://github.com/MCP-Security-Research/pysealer/issues
|
|
35
|
+
Project-URL: Repository, https://github.com/MCP-Security-Research/pysealer
|
|
36
|
+
|
|
37
|
+
# pysealer
|
|
38
|
+
|
|
39
|
+
Cryptographically sign Python functions and classes for defense-in-depth security
|
|
40
|
+
|
|
41
|
+
> 💡 **code version controls code**
|
|
42
|
+
|
|
43
|
+
- 🦀 Built with the [maturin build system](https://www.maturin.rs/) for easy Rust-Python packaging
|
|
44
|
+
- 🔗 [PyO3](https://pyo3.rs/v0.27.1/index.html) bindings for seamless Python-Rust integration
|
|
45
|
+
- 🔏 [Ed25519](https://docs.rs/ed25519-dalek/latest/ed25519_dalek/) signatures to ensure code integrity and authorship
|
|
46
|
+
- 🖥️ [Typer](https://typer.tiangolo.com/) for a clean and user-friendly command line interface
|
|
47
|
+
|
|
48
|
+
Pysealer helps you maintain code integrity by automatically adding cryptographic signatures to your Python functions and classes. Each function or class receives a unique decorator containing a cryptographic signature that verifies both authorship and integrity, making it easy to detect unauthorized code modifications.
|
|
49
|
+
|
|
50
|
+
## Table of Contents
|
|
51
|
+
|
|
52
|
+
1. [Getting Started](#getting-started)
|
|
53
|
+
2. [Usage](#usage)
|
|
54
|
+
3. [How It Works](#how-it-works)
|
|
55
|
+
4. [Model Context Protocol (MCP) Security Use Cases](#model-context-protocol-mcp-security-use-cases)
|
|
56
|
+
5. [Contributing](#contributing)
|
|
57
|
+
6. [License](#license)
|
|
58
|
+
|
|
59
|
+
## Getting Started
|
|
60
|
+
|
|
61
|
+
```shell
|
|
62
|
+
pip install pysealer
|
|
63
|
+
# or
|
|
64
|
+
uv pip install pysealer
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```shell
|
|
70
|
+
pysealer init [ENV_FILE] # Initialize the pysealer tool by generating and saving keys to an ENV_FILE (default: .env)
|
|
71
|
+
pysealer decorate <file.py>... # Add cryptographic decorators to all functions/classes in one or more .py files
|
|
72
|
+
pysealer check <file.py>... # Verify the integrity and validity of pysealer decorators in one or more .py files
|
|
73
|
+
pysealer remove <file.py>... # Remove all pysealer decorators from one or more .py files
|
|
74
|
+
pysealer --help # Show all available commands and options
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## How It Works
|
|
78
|
+
|
|
79
|
+
Pysealer works by automatically injecting cryptographic decorators into your Python functions and classes. Here's how the process works:
|
|
80
|
+
|
|
81
|
+
### Step-by-Step Example
|
|
82
|
+
|
|
83
|
+
Suppose you have a file `fibonacci.py`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
def fibonacci(n):
|
|
87
|
+
if n <= 0:
|
|
88
|
+
return 0
|
|
89
|
+
elif n == 1:
|
|
90
|
+
return 1
|
|
91
|
+
else:
|
|
92
|
+
return fibonacci(n-1) + fibonacci(n-2)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
#### 1. Decorate the file
|
|
96
|
+
|
|
97
|
+
```shell
|
|
98
|
+
pysealer decorate examples/fibonacci.py
|
|
99
|
+
|
|
100
|
+
Successfully added decorators to 1 file:
|
|
101
|
+
✓ /path/to/examples/fibonacci.py
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
@pysealer._GnCLaWr9B6TD524JZ3v1CENXmo5Drwfgvc9arVagbghQ6hMH4Aqc8whs3Tf57pkTjsAVNDybviW9XG5Eu3JSP6T()
|
|
106
|
+
def fibonacci(n):
|
|
107
|
+
if n <= 0:
|
|
108
|
+
return 0
|
|
109
|
+
elif n == 1:
|
|
110
|
+
return 1
|
|
111
|
+
else:
|
|
112
|
+
return fibonacci(n-1) + fibonacci(n-2)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### 2. Check integrity
|
|
116
|
+
|
|
117
|
+
```shell
|
|
118
|
+
pysealer check examples/fibonacci.py
|
|
119
|
+
|
|
120
|
+
All decorators are valid in 1 file:
|
|
121
|
+
✓ /path/to/examples/fibonacci.py: 1 decorators valid
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### 3. Tamper with the code (change return 0 to return 42)
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
@pysealer._GnCLaWr9B6TD524JZ3v1CENXmo5Drwfgvc9arVagbghQ6hMH4Aqc8whs3Tf57pkTjsAVNDybviW9XG5Eu3JSP6T()
|
|
128
|
+
def fibonacci(n):
|
|
129
|
+
if n <= 0:
|
|
130
|
+
return 42
|
|
131
|
+
elif n == 1:
|
|
132
|
+
return 1
|
|
133
|
+
else:
|
|
134
|
+
return fibonacci(n-1) + fibonacci(n-2)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### 4. Check again
|
|
138
|
+
|
|
139
|
+
```shell
|
|
140
|
+
pysealer check examples/fibonacci.py
|
|
141
|
+
|
|
142
|
+
1/1 decorators failed verification across 1 file:
|
|
143
|
+
✗ /path/to/examples/fibonacci.py: 1/1 decorators failed
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Model Context Protocol (MCP) Security Use Cases
|
|
147
|
+
|
|
148
|
+
One use case of Pysealer is to protect MCP servers from upstream attacks by cryptographically signing tool functions and their docstrings. Since LLMs rely on docstrings to understand tool behavior, attackers can inject malicious instructions or create fake tools that mimic legitimate ones. Pysealer's signatures ensure tool authenticity and detect tampering because any modification to code or docstrings breaks the signature and flags compromised tools.
|
|
149
|
+
|
|
150
|
+
- **Detect Version Control Changes**
|
|
151
|
+
- Automatically detect unauthorized code modifications through cryptographic signatures
|
|
152
|
+
- Each function's decorator contains a signature based on its code and docstring
|
|
153
|
+
- Any mismatch between code and signature is immediately flagged
|
|
154
|
+
|
|
155
|
+
- **Defense-in-Depth for Source Control**
|
|
156
|
+
- Add an additional security layer to version control systems
|
|
157
|
+
- Complement existing security measures with cryptographic verification
|
|
158
|
+
- Reduce risk through multiple layers of protection
|
|
159
|
+
|
|
160
|
+
## Contributing
|
|
161
|
+
|
|
162
|
+
**🙌 Contributions are welcome!**
|
|
163
|
+
|
|
164
|
+
If you have suggestions, bug reports, or want to help improve Pysealer, feel free to open an [issue](https://github.com/MCP-Security-Research/pysealer/issues) or submit a pull request.
|
|
165
|
+
|
|
166
|
+
All ideas and contributions are appreciated—thanks for helping make pysealer better!
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
Pysealer is licensed under the MIT License. See [LICENSE](LICENSE) for details.
|
|
171
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pysealer/__init__.py,sha256=qxCa8932UYgSwwcWv1TP6uRWxhSn3QPeot3ItEkFQCw,944
|
|
2
|
+
pysealer/_pysealer.pypy311-pp73-aarch64-linux-gnu.so,sha256=FdQN-Z74Cq7XGkypQQWK1y3r7QSTf2gvgJyCIzLiAmg,660696
|
|
3
|
+
pysealer/add_decorators.py,sha256=QkjZw7ckLd06RIg77nhscgzXk8SyLdJ2kNyQ0BTjS4g,9049
|
|
4
|
+
pysealer/check_decorators.py,sha256=_fEsd94qN_ypMOePF75QcL--KrIXFQunyXXAZIeexTM,7401
|
|
5
|
+
pysealer/cli.py,sha256=Ysk3b8q17YboAq5mxi3uwB1t4WPF2KcRJNeyQkJ05fs,16490
|
|
6
|
+
pysealer/dummy_decorators.py,sha256=9ORNnMZ6lT5e1gU4Dt9lifAEDfjJ1wCXGiOUfMiqP-w,2807
|
|
7
|
+
pysealer/git_diff.py,sha256=hgFhjoE5Yom-LF31BzwtO5jmohlW4FfWvfp8BDZgeNY,6912
|
|
8
|
+
pysealer/github_secrets.py,sha256=0Hms2VEewZl2Dv1G1eVUBPLouiy8mcZFrxlLftUOpFg,6331
|
|
9
|
+
pysealer/remove_decorators.py,sha256=v1VpxFid8kqqoj-NuWSWWzCoYUwl1XVYyGdTE8BLbeI,3276
|
|
10
|
+
pysealer/setup.py,sha256=M8wgv4GeVbbC_t0a8PBD5Hn6piqGTFIUTlo1ds6JtAc,4206
|
|
11
|
+
pysealer-0.6.0.dist-info/METADATA,sha256=Re0ucu5klXyfnolk1K0evoZ1zGAUd0uQ2Jz334XHmqg,6229
|
|
12
|
+
pysealer-0.6.0.dist-info/WHEEL,sha256=lvTdq2g8minGuOELGjpnOe0IufZy6sC9B2wysIpb14o,163
|
|
13
|
+
pysealer-0.6.0.dist-info/entry_points.txt,sha256=DUDRyFGp10a8FMaLUFE6X94kCD2dciDY66ISXuUySmA,45
|
|
14
|
+
pysealer-0.6.0.dist-info/licenses/LICENSE,sha256=FtcYsMyXNwAqNhOMxR3TwptCUnwb5coRK_UQyWGLJnc,1067
|
|
15
|
+
pysealer-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Aidan Dyga
|
|
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.
|