pysealer 0.1.3__cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.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.
Potentially problematic release.
This version of pysealer might be problematic. Click here for more details.
- pyseal/__init__.py +24 -0
- pyseal/_pyseal.cpython-313t-powerpc64le-linux-gnu.so +0 -0
- pyseal/add_decorators.py +215 -0
- pyseal/check_decorators.py +179 -0
- pyseal/cli.py +284 -0
- pyseal/dummy_decorators.py +83 -0
- pyseal/remove_decorators.py +88 -0
- pyseal/setup.py +137 -0
- pysealer-0.1.3.dist-info/METADATA +168 -0
- pysealer-0.1.3.dist-info/RECORD +13 -0
- pysealer-0.1.3.dist-info/WHEEL +5 -0
- pysealer-0.1.3.dist-info/entry_points.txt +2 -0
- pysealer-0.1.3.dist-info/licenses/LICENSE +21 -0
pyseal/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Pyseal package entry point
|
|
2
|
+
|
|
3
|
+
This package serves as a bridge between the Python command line interface and the
|
|
4
|
+
underlying Rust implementation (compiled as _pyseal module). It exposes the
|
|
5
|
+
core functionality for adding version control decorators to Python functions.
|
|
6
|
+
|
|
7
|
+
This module also dynamically provides decorator placeholders (e.g. @pyseal._<sig>)
|
|
8
|
+
so that decorated functions remain importable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Define the rust to python module version and functions
|
|
12
|
+
from ._pyseal import generate_keypair, generate_signature, verify_signature
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.3"
|
|
15
|
+
__all__ = ["generate_keypair", "generate_signature", "verify_signature"]
|
|
16
|
+
|
|
17
|
+
# Ensure dummy decorators are registered on import
|
|
18
|
+
from . import dummy_decorators
|
|
19
|
+
|
|
20
|
+
# Allow dynamic decorator resolution for @pyseal._<sig>()
|
|
21
|
+
def __getattr__(name):
|
|
22
|
+
if name.startswith("_"):
|
|
23
|
+
return dummy_decorators._dummy_decorator
|
|
24
|
+
raise AttributeError(f"module 'pyseal' has no attribute '{name}'")
|
|
Binary file
|
pyseal/add_decorators.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Automatically add cryptographic decorators to all functions and classes in a python file."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import copy
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from pyseal import generate_signature
|
|
7
|
+
from .setup import get_private_key
|
|
8
|
+
|
|
9
|
+
def add_decorators(file_path: str) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Parse a Python file, add decorators to all functions and classes, and return the modified code.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
file_path: Path to the Python file to process
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Modified Python source code as a string
|
|
18
|
+
"""
|
|
19
|
+
# Read the entire file content into a string
|
|
20
|
+
with open(file_path, 'r') as f:
|
|
21
|
+
content = f.read()
|
|
22
|
+
|
|
23
|
+
# Split content into lines for manipulation
|
|
24
|
+
lines = content.split('\n')
|
|
25
|
+
|
|
26
|
+
# Dynamically add 'import pyseal' grouped with other imports if not present
|
|
27
|
+
has_import_pyseal = any(
|
|
28
|
+
line.strip() == 'import pyseal' or line.strip().startswith('import pyseal') or line.strip().startswith('from pyseal')
|
|
29
|
+
for line in lines
|
|
30
|
+
)
|
|
31
|
+
if not has_import_pyseal:
|
|
32
|
+
# Find the import block
|
|
33
|
+
import_indices = [i for i, line in enumerate(lines) if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
|
34
|
+
if import_indices:
|
|
35
|
+
# Insert after the last import in the block
|
|
36
|
+
last_import = import_indices[-1]
|
|
37
|
+
lines.insert(last_import + 1, 'import pyseal')
|
|
38
|
+
else:
|
|
39
|
+
# No import block found, insert after shebang/docstring/comments as before
|
|
40
|
+
insert_at = 0
|
|
41
|
+
if lines and lines[0].startswith('#!'):
|
|
42
|
+
insert_at = 1
|
|
43
|
+
while insert_at < len(lines) and (lines[insert_at].strip() == '' or lines[insert_at].strip().startswith('"""') or lines[insert_at].strip().startswith("''")):
|
|
44
|
+
if lines[insert_at].strip().startswith('"""') or lines[insert_at].strip().startswith("''"):
|
|
45
|
+
quote = lines[insert_at].strip()[:3]
|
|
46
|
+
insert_at += 1
|
|
47
|
+
while insert_at < len(lines) and not lines[insert_at].strip().endswith(quote):
|
|
48
|
+
insert_at += 1
|
|
49
|
+
if insert_at < len(lines):
|
|
50
|
+
insert_at += 1
|
|
51
|
+
else:
|
|
52
|
+
insert_at += 1
|
|
53
|
+
lines.insert(insert_at, 'import pyseal')
|
|
54
|
+
|
|
55
|
+
# Parse the Python source code into an Abstract Syntax Tree (AST)
|
|
56
|
+
content = '\n'.join(lines)
|
|
57
|
+
tree = ast.parse(content)
|
|
58
|
+
|
|
59
|
+
# First pass: Remove existing pyseal decorators
|
|
60
|
+
lines_to_remove = set()
|
|
61
|
+
for node in ast.walk(tree):
|
|
62
|
+
if type(node).__name__ in ("FunctionDef", "AsyncFunctionDef", "ClassDef"):
|
|
63
|
+
if hasattr(node, 'decorator_list'):
|
|
64
|
+
for decorator in node.decorator_list:
|
|
65
|
+
is_pyseal_decorator = False
|
|
66
|
+
|
|
67
|
+
if isinstance(decorator, ast.Name):
|
|
68
|
+
if decorator.id.startswith("pyseal"):
|
|
69
|
+
is_pyseal_decorator = True
|
|
70
|
+
elif isinstance(decorator, ast.Attribute):
|
|
71
|
+
if isinstance(decorator.value, ast.Name) and decorator.value.id == "pyseal":
|
|
72
|
+
is_pyseal_decorator = True
|
|
73
|
+
elif isinstance(decorator, ast.Call):
|
|
74
|
+
func = decorator.func
|
|
75
|
+
if isinstance(func, ast.Attribute):
|
|
76
|
+
if isinstance(func.value, ast.Name) and func.value.id == "pyseal":
|
|
77
|
+
is_pyseal_decorator = True
|
|
78
|
+
elif isinstance(func, ast.Name) and func.id.startswith("pyseal"):
|
|
79
|
+
is_pyseal_decorator = True
|
|
80
|
+
|
|
81
|
+
if is_pyseal_decorator:
|
|
82
|
+
# Mark this line for removal (convert to 0-indexed)
|
|
83
|
+
lines_to_remove.add(decorator.lineno - 1)
|
|
84
|
+
|
|
85
|
+
# Remove the marked lines (in reverse order to preserve indices)
|
|
86
|
+
for line_idx in sorted(lines_to_remove, reverse=True):
|
|
87
|
+
del lines[line_idx]
|
|
88
|
+
|
|
89
|
+
# Re-parse the content after removing decorators to get updated line numbers
|
|
90
|
+
modified_content = '\n'.join(lines)
|
|
91
|
+
tree = ast.parse(modified_content)
|
|
92
|
+
|
|
93
|
+
# Build parent map for all nodes
|
|
94
|
+
parent_map = {}
|
|
95
|
+
for parent in ast.walk(tree):
|
|
96
|
+
for child in ast.iter_child_nodes(parent):
|
|
97
|
+
parent_map[child] = parent
|
|
98
|
+
|
|
99
|
+
decorators_to_add = []
|
|
100
|
+
|
|
101
|
+
for node in ast.walk(tree):
|
|
102
|
+
node_type = type(node).__name__
|
|
103
|
+
|
|
104
|
+
# Only decorate:
|
|
105
|
+
# - Top-level functions (not inside a class)
|
|
106
|
+
# - Top-level classes
|
|
107
|
+
if node_type in ("FunctionDef", "AsyncFunctionDef"):
|
|
108
|
+
parent = parent_map.get(node)
|
|
109
|
+
if isinstance(parent, ast.ClassDef):
|
|
110
|
+
continue # skip methods inside classes
|
|
111
|
+
elif node_type == "ClassDef":
|
|
112
|
+
pass # always decorate classes
|
|
113
|
+
else:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Extract the complete source code of this function/class for hashing
|
|
117
|
+
node_clone = copy.deepcopy(node)
|
|
118
|
+
|
|
119
|
+
# Filter out pyseal decorators
|
|
120
|
+
if hasattr(node_clone, 'decorator_list'):
|
|
121
|
+
filtered_decorators = []
|
|
122
|
+
for decorator in node_clone.decorator_list:
|
|
123
|
+
should_keep = True
|
|
124
|
+
if isinstance(decorator, ast.Name):
|
|
125
|
+
if decorator.id.startswith("pyseal"):
|
|
126
|
+
should_keep = False
|
|
127
|
+
elif isinstance(decorator, ast.Attribute):
|
|
128
|
+
if isinstance(decorator.value, ast.Name) and decorator.value.id == "pyseal":
|
|
129
|
+
should_keep = False
|
|
130
|
+
elif isinstance(decorator, ast.Call):
|
|
131
|
+
func = decorator.func
|
|
132
|
+
if isinstance(func, ast.Attribute):
|
|
133
|
+
if isinstance(func.value, ast.Name) and func.value.id == "pyseal":
|
|
134
|
+
should_keep = False
|
|
135
|
+
elif isinstance(func, ast.Name) and func.id.startswith("pyseal"):
|
|
136
|
+
should_keep = False
|
|
137
|
+
if should_keep:
|
|
138
|
+
filtered_decorators.append(decorator)
|
|
139
|
+
node_clone.decorator_list = filtered_decorators
|
|
140
|
+
|
|
141
|
+
module_wrapper = ast.Module(body=[node_clone], type_ignores=[])
|
|
142
|
+
function_source = ast.unparse(module_wrapper)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
private_key = get_private_key()
|
|
146
|
+
except (FileNotFoundError, ValueError) as e:
|
|
147
|
+
raise RuntimeError(f"Cannot add decorators: {e}. Please run 'pyseal init' first.")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
signature = generate_signature(function_source, private_key)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
raise RuntimeError(f"Failed to generate signature: {e}")
|
|
153
|
+
|
|
154
|
+
decorator_line = node.lineno - 1
|
|
155
|
+
if hasattr(node, 'decorator_list') and node.decorator_list:
|
|
156
|
+
decorator_line = node.decorator_list[0].lineno - 1
|
|
157
|
+
|
|
158
|
+
decorators_to_add.append((decorator_line, node.col_offset, signature))
|
|
159
|
+
|
|
160
|
+
# Sort in reverse order to add from bottom to top (preserves line numbers)
|
|
161
|
+
decorators_to_add.sort(reverse=True)
|
|
162
|
+
|
|
163
|
+
# Add decorators to the lines
|
|
164
|
+
for line_idx, col_offset, signature in decorators_to_add:
|
|
165
|
+
indent = ' ' * col_offset
|
|
166
|
+
decorator_line = f"{indent}@pyseal._{signature}()"
|
|
167
|
+
lines.insert(line_idx, decorator_line)
|
|
168
|
+
|
|
169
|
+
# Join lines back together
|
|
170
|
+
modified_code = '\n'.join(lines)
|
|
171
|
+
|
|
172
|
+
return modified_code
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def add_decorators_to_folder(folder_path: str) -> list[str]:
|
|
176
|
+
"""
|
|
177
|
+
Add decorators to all Python files in a folder.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
folder_path: Path to the folder containing Python files
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of file paths that were successfully decorated
|
|
184
|
+
"""
|
|
185
|
+
folder = Path(folder_path)
|
|
186
|
+
|
|
187
|
+
if not folder.exists():
|
|
188
|
+
raise FileNotFoundError(f"Folder '{folder_path}' does not exist.")
|
|
189
|
+
|
|
190
|
+
if not folder.is_dir():
|
|
191
|
+
raise NotADirectoryError(f"'{folder_path}' is not a directory.")
|
|
192
|
+
|
|
193
|
+
# Find all Python files in the folder (non-recursive)
|
|
194
|
+
python_files = list(folder.glob('*.py'))
|
|
195
|
+
|
|
196
|
+
if not python_files:
|
|
197
|
+
raise ValueError(f"No Python files found in '{folder_path}'.")
|
|
198
|
+
|
|
199
|
+
decorated_files = []
|
|
200
|
+
errors = []
|
|
201
|
+
|
|
202
|
+
for py_file in python_files:
|
|
203
|
+
try:
|
|
204
|
+
modified_code = add_decorators(str(py_file))
|
|
205
|
+
with open(py_file, 'w') as f:
|
|
206
|
+
f.write(modified_code)
|
|
207
|
+
decorated_files.append(str(py_file))
|
|
208
|
+
except Exception as e:
|
|
209
|
+
errors.append((str(py_file), str(e)))
|
|
210
|
+
|
|
211
|
+
if errors:
|
|
212
|
+
error_msg = "\n".join([f" - {file}: {error}" for file, error in errors])
|
|
213
|
+
raise RuntimeError(f"Failed to decorate some files:\n{error_msg}")
|
|
214
|
+
|
|
215
|
+
return decorated_files
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Automatically verify cryptographic decorators for all functions and classes in a python file."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import copy
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict
|
|
7
|
+
from pyseal import verify_signature
|
|
8
|
+
from .setup import get_public_key
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_decorators(file_path: str) -> Dict[str, dict]:
|
|
12
|
+
"""
|
|
13
|
+
Parse a Python file and verify all pyseal cryptographic decorators.
|
|
14
|
+
|
|
15
|
+
This function checks that each function/class with a pyseal decorator has a valid
|
|
16
|
+
signature that matches the current source code of that function/class.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
file_path: Path to the Python file to verify
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Dictionary mapping function/class names to their verification results:
|
|
23
|
+
{
|
|
24
|
+
"function_name": {
|
|
25
|
+
"valid": bool, # Whether signature is valid
|
|
26
|
+
"signature": str, # The signature found in decorator
|
|
27
|
+
"message": str, # Success or error message
|
|
28
|
+
"has_decorator": bool # Whether function has pyseal decorator
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
# Read the file content
|
|
33
|
+
with open(file_path, 'r') as f:
|
|
34
|
+
content = f.read()
|
|
35
|
+
|
|
36
|
+
# Parse the Python source code into an AST
|
|
37
|
+
tree = ast.parse(content)
|
|
38
|
+
|
|
39
|
+
# Get the public key for verification
|
|
40
|
+
try:
|
|
41
|
+
public_key = get_public_key()
|
|
42
|
+
except (FileNotFoundError, ValueError) as e:
|
|
43
|
+
raise RuntimeError(f"Cannot verify decorators: {e}. Please run 'pyseal init' first.")
|
|
44
|
+
|
|
45
|
+
# Dictionary to store results
|
|
46
|
+
results = {}
|
|
47
|
+
|
|
48
|
+
# Iterate through each node in the AST
|
|
49
|
+
for node in ast.walk(tree):
|
|
50
|
+
node_type = type(node).__name__
|
|
51
|
+
|
|
52
|
+
# Check if this node is a function or class definition
|
|
53
|
+
if node_type in ("FunctionDef", "AsyncFunctionDef", "ClassDef"):
|
|
54
|
+
name = node.name
|
|
55
|
+
|
|
56
|
+
# Look for pyseal decorator
|
|
57
|
+
signature_from_decorator = None
|
|
58
|
+
has_pyseal_decorator = False
|
|
59
|
+
|
|
60
|
+
if hasattr(node, 'decorator_list'):
|
|
61
|
+
for decorator in node.decorator_list:
|
|
62
|
+
# Check if decorator is a Call node (e.g., @pyseal._<signature>())
|
|
63
|
+
if isinstance(decorator, ast.Call):
|
|
64
|
+
func = decorator.func
|
|
65
|
+
# Check if it's pyseal._<signature>
|
|
66
|
+
if isinstance(func, ast.Attribute):
|
|
67
|
+
if isinstance(func.value, ast.Name) and func.value.id == "pyseal":
|
|
68
|
+
attr_name = func.attr
|
|
69
|
+
# Extract signature (remove leading underscore)
|
|
70
|
+
if attr_name.startswith('_'):
|
|
71
|
+
signature_from_decorator = attr_name[1:]
|
|
72
|
+
has_pyseal_decorator = True
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
# Initialize result for this function/class
|
|
76
|
+
result = {
|
|
77
|
+
"has_decorator": has_pyseal_decorator,
|
|
78
|
+
"valid": False,
|
|
79
|
+
"signature": signature_from_decorator,
|
|
80
|
+
"message": ""
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if not has_pyseal_decorator:
|
|
84
|
+
result["message"] = "No pyseal decorator found"
|
|
85
|
+
results[name] = result
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Extract the source code without pyseal decorators for verification
|
|
89
|
+
node_clone = copy.deepcopy(node)
|
|
90
|
+
|
|
91
|
+
# Filter out pyseal decorators from the clone
|
|
92
|
+
if hasattr(node_clone, 'decorator_list'):
|
|
93
|
+
filtered_decorators = []
|
|
94
|
+
|
|
95
|
+
for decorator in node_clone.decorator_list:
|
|
96
|
+
should_keep = True
|
|
97
|
+
|
|
98
|
+
# Check if decorator is a simple Name node starting with "pyseal"
|
|
99
|
+
if isinstance(decorator, ast.Name):
|
|
100
|
+
if decorator.id.startswith("pyseal"):
|
|
101
|
+
should_keep = False
|
|
102
|
+
|
|
103
|
+
# Check if decorator is an Attribute node (e.g., pyseal.something)
|
|
104
|
+
elif isinstance(decorator, ast.Attribute):
|
|
105
|
+
if isinstance(decorator.value, ast.Name) and decorator.value.id == "pyseal":
|
|
106
|
+
should_keep = False
|
|
107
|
+
|
|
108
|
+
# Check if decorator is a Call node
|
|
109
|
+
elif isinstance(decorator, ast.Call):
|
|
110
|
+
func = decorator.func
|
|
111
|
+
# Check if call is to pyseal.something()
|
|
112
|
+
if isinstance(func, ast.Attribute):
|
|
113
|
+
if isinstance(func.value, ast.Name) and func.value.id == "pyseal":
|
|
114
|
+
should_keep = False
|
|
115
|
+
# Check if call is to pyseal_something()
|
|
116
|
+
elif isinstance(func, ast.Name) and func.id.startswith("pyseal"):
|
|
117
|
+
should_keep = False
|
|
118
|
+
|
|
119
|
+
if should_keep:
|
|
120
|
+
filtered_decorators.append(decorator)
|
|
121
|
+
|
|
122
|
+
node_clone.decorator_list = filtered_decorators
|
|
123
|
+
|
|
124
|
+
# Convert the filtered node back to source code
|
|
125
|
+
module_wrapper = ast.Module(body=[node_clone], type_ignores=[])
|
|
126
|
+
function_source = ast.unparse(module_wrapper)
|
|
127
|
+
|
|
128
|
+
# Verify the signature
|
|
129
|
+
try:
|
|
130
|
+
is_valid = verify_signature(function_source, signature_from_decorator, public_key)
|
|
131
|
+
|
|
132
|
+
result["valid"] = is_valid
|
|
133
|
+
if is_valid:
|
|
134
|
+
result["message"] = "✓ Signature valid - code has not been tampered with"
|
|
135
|
+
else:
|
|
136
|
+
result["message"] = "✗ Signature invalid - code may have been modified"
|
|
137
|
+
|
|
138
|
+
except Exception as e:
|
|
139
|
+
result["message"] = f"✗ Error verifying signature: {e}"
|
|
140
|
+
|
|
141
|
+
results[name] = result
|
|
142
|
+
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_decorators_in_folder(folder_path: str) -> Dict[str, Dict[str, dict]]:
|
|
147
|
+
"""
|
|
148
|
+
Check decorators in all Python files in a folder.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
folder_path: Path to the folder containing Python files
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Dictionary mapping file paths to their verification results
|
|
155
|
+
"""
|
|
156
|
+
folder = Path(folder_path)
|
|
157
|
+
|
|
158
|
+
if not folder.exists():
|
|
159
|
+
raise FileNotFoundError(f"Folder '{folder_path}' does not exist.")
|
|
160
|
+
|
|
161
|
+
if not folder.is_dir():
|
|
162
|
+
raise NotADirectoryError(f"'{folder_path}' is not a directory.")
|
|
163
|
+
|
|
164
|
+
# Find all Python files in the folder (non-recursive)
|
|
165
|
+
python_files = list(folder.glob('*.py'))
|
|
166
|
+
|
|
167
|
+
if not python_files:
|
|
168
|
+
raise ValueError(f"No Python files found in '{folder_path}'.")
|
|
169
|
+
|
|
170
|
+
all_results = {}
|
|
171
|
+
|
|
172
|
+
for py_file in python_files:
|
|
173
|
+
try:
|
|
174
|
+
results = check_decorators(str(py_file))
|
|
175
|
+
all_results[str(py_file)] = results
|
|
176
|
+
except Exception as e:
|
|
177
|
+
all_results[str(py_file)] = {"error": str(e)}
|
|
178
|
+
|
|
179
|
+
return all_results
|
pyseal/cli.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for pyseal.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
- init: Initialize pyseal with a new keypair and .env file.
|
|
6
|
+
- lock: Add pyseal decorators to all functions and classes in a Python file.
|
|
7
|
+
- check: Check the integrity and validity of pyseal decorators in a Python file.
|
|
8
|
+
- remove: Remove all pyseal decorators from a Python file.
|
|
9
|
+
|
|
10
|
+
Use `pyseal --help` to see available options and command details.
|
|
11
|
+
Use `pyseal --version` to see the current version of pyseal installed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from typing_extensions import Annotated
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .setup import setup_keypair
|
|
21
|
+
from .add_decorators import add_decorators, add_decorators_to_folder
|
|
22
|
+
from .check_decorators import check_decorators, check_decorators_in_folder
|
|
23
|
+
from .remove_decorators import remove_decorators, remove_decorators_from_folder
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="pyseal",
|
|
27
|
+
help="Version control your Python functions and classes with cryptographic decorators",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def version_callback(value: bool):
|
|
33
|
+
"""Helper function to display version information."""
|
|
34
|
+
if value:
|
|
35
|
+
typer.echo(f"pyseal {__version__}")
|
|
36
|
+
raise typer.Exit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback()
|
|
40
|
+
def version(
|
|
41
|
+
version: Annotated[
|
|
42
|
+
bool,
|
|
43
|
+
typer.Option("--version", help="Report the current version of pyseal installed.", callback=version_callback, is_eager=True)
|
|
44
|
+
] = False
|
|
45
|
+
):
|
|
46
|
+
"""Report the current version of pyseal installed."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def init(
|
|
52
|
+
env_file: Annotated[
|
|
53
|
+
str,
|
|
54
|
+
typer.Argument(help="Path to the .env file")
|
|
55
|
+
] = ".env"
|
|
56
|
+
):
|
|
57
|
+
"""Initialize pyseal with an .env file."""
|
|
58
|
+
try:
|
|
59
|
+
env_path = Path(env_file)
|
|
60
|
+
|
|
61
|
+
# Generate and store keypair (will raise error if keys already exist)
|
|
62
|
+
setup_keypair(env_path)
|
|
63
|
+
typer.echo(typer.style("Successfully initialized pyseal!", fg=typer.colors.BLUE, bold=True))
|
|
64
|
+
typer.echo(f"🔑 Keypair generated and stored in {env_path}")
|
|
65
|
+
typer.echo("⚠️ Keep your .env file secure and add it to .gitignore!")
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
typer.echo(typer.style(f"Error during initialization: {e}", fg=typer.colors.RED, bold=True), err=True)
|
|
69
|
+
raise typer.Exit(code=1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def lock(
|
|
74
|
+
file_path: Annotated[
|
|
75
|
+
str,
|
|
76
|
+
typer.Argument(help="Path to the Python file or folder to decorate")
|
|
77
|
+
]
|
|
78
|
+
):
|
|
79
|
+
"""Add decorators to all functions and classes in a Python file or all Python files in a folder."""
|
|
80
|
+
path = Path(file_path)
|
|
81
|
+
|
|
82
|
+
# Validate path exists
|
|
83
|
+
if not path.exists():
|
|
84
|
+
typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
|
|
85
|
+
raise typer.Exit(code=1)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
# Handle folder path
|
|
89
|
+
if path.is_dir():
|
|
90
|
+
resolved_path = str(path.resolve())
|
|
91
|
+
decorated_files = add_decorators_to_folder(resolved_path)
|
|
92
|
+
|
|
93
|
+
typer.echo(typer.style(f"Successfully added decorators to {len(decorated_files)} files:", fg=typer.colors.BLUE, bold=True))
|
|
94
|
+
for file in decorated_files:
|
|
95
|
+
typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file}")
|
|
96
|
+
|
|
97
|
+
# Handle file path
|
|
98
|
+
else:
|
|
99
|
+
# Validate it's a Python file
|
|
100
|
+
if not path.suffix == '.py':
|
|
101
|
+
typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
|
|
104
|
+
# Add decorators to all functions and classes in the file
|
|
105
|
+
resolved_path = str(path.resolve())
|
|
106
|
+
modified_code = add_decorators(resolved_path)
|
|
107
|
+
|
|
108
|
+
# Write the modified code back to the file
|
|
109
|
+
with open(resolved_path, 'w') as f:
|
|
110
|
+
f.write(modified_code)
|
|
111
|
+
|
|
112
|
+
typer.echo(typer.style(f"Successfully added decorators to 1 file:", fg=typer.colors.BLUE, bold=True))
|
|
113
|
+
typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
|
|
114
|
+
|
|
115
|
+
except (RuntimeError, FileNotFoundError, NotADirectoryError, ValueError) as e:
|
|
116
|
+
typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
|
|
117
|
+
raise typer.Exit(code=1)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
typer.echo(typer.style(f"Unexpected error while locking file: {e}", fg=typer.colors.RED, bold=True), err=True)
|
|
120
|
+
raise typer.Exit(code=1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def check(
|
|
125
|
+
file_path: Annotated[
|
|
126
|
+
str,
|
|
127
|
+
typer.Argument(help="Path to the Python file or folder to check")
|
|
128
|
+
]
|
|
129
|
+
):
|
|
130
|
+
"""Check the integrity of decorators in a Python file or all Python files in a folder."""
|
|
131
|
+
path = Path(file_path)
|
|
132
|
+
|
|
133
|
+
# Validate path exists
|
|
134
|
+
if not path.exists():
|
|
135
|
+
typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
# Handle folder path
|
|
140
|
+
if path.is_dir():
|
|
141
|
+
resolved_path = str(path.resolve())
|
|
142
|
+
all_results = check_decorators_in_folder(resolved_path)
|
|
143
|
+
|
|
144
|
+
total_decorated = 0
|
|
145
|
+
total_valid = 0
|
|
146
|
+
total_files = 0
|
|
147
|
+
files_with_issues = []
|
|
148
|
+
|
|
149
|
+
for file_path, results in all_results.items():
|
|
150
|
+
# Skip files with errors
|
|
151
|
+
if "error" in results:
|
|
152
|
+
typer.echo(typer.style(f"✗ {file_path}: {results['error']}", fg=typer.colors.RED))
|
|
153
|
+
files_with_issues.append(file_path)
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
total_files += 1
|
|
157
|
+
decorated_count = sum(1 for r in results.values() if r["has_decorator"])
|
|
158
|
+
valid_count = sum(1 for r in results.values() if r["valid"])
|
|
159
|
+
|
|
160
|
+
total_decorated += decorated_count
|
|
161
|
+
total_valid += valid_count
|
|
162
|
+
|
|
163
|
+
# Track files with validation failures
|
|
164
|
+
if decorated_count > 0 and valid_count < decorated_count:
|
|
165
|
+
files_with_issues.append(file_path)
|
|
166
|
+
|
|
167
|
+
# Summary header
|
|
168
|
+
if total_decorated == 0:
|
|
169
|
+
typer.echo("⚠️ No pyseal decorators found in any files.")
|
|
170
|
+
elif total_valid == total_decorated:
|
|
171
|
+
typer.echo(typer.style(f"All decorators are valid in {total_files} files:", fg=typer.colors.BLUE, bold=True))
|
|
172
|
+
else:
|
|
173
|
+
typer.echo(typer.style(f"{total_decorated - total_valid}/{total_decorated} decorators failed verification across {total_files} files:", fg=typer.colors.BLUE, bold=True), err=True)
|
|
174
|
+
|
|
175
|
+
# File-by-file details
|
|
176
|
+
for file_path, results in all_results.items():
|
|
177
|
+
if "error" in results:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
decorated_count = sum(1 for r in results.values() if r["has_decorator"])
|
|
181
|
+
valid_count = sum(1 for r in results.values() if r["valid"])
|
|
182
|
+
|
|
183
|
+
if decorated_count > 0:
|
|
184
|
+
if valid_count == decorated_count:
|
|
185
|
+
typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file_path}: {typer.style(f'{decorated_count} decorators valid', fg=typer.colors.GREEN)}")
|
|
186
|
+
else:
|
|
187
|
+
typer.echo(f" {typer.style('✗', fg=typer.colors.RED)} {file_path}: {typer.style(f'{decorated_count - valid_count}/{decorated_count} decorators failed', fg=typer.colors.RED)}")
|
|
188
|
+
|
|
189
|
+
# Exit with error if there were failures
|
|
190
|
+
if total_decorated > 0 and total_valid < total_decorated:
|
|
191
|
+
raise typer.Exit(code=1)
|
|
192
|
+
|
|
193
|
+
# Handle file path
|
|
194
|
+
else:
|
|
195
|
+
# Validate it's a Python file
|
|
196
|
+
if not path.suffix == '.py':
|
|
197
|
+
typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
|
|
198
|
+
raise typer.Exit(code=1)
|
|
199
|
+
|
|
200
|
+
# Check all decorators in the file
|
|
201
|
+
resolved_path = str(path.resolve())
|
|
202
|
+
results = check_decorators(resolved_path)
|
|
203
|
+
|
|
204
|
+
# Return success if all decorated functions are valid
|
|
205
|
+
decorated_count = sum(1 for r in results.values() if r["has_decorator"])
|
|
206
|
+
valid_count = sum(1 for r in results.values() if r["valid"])
|
|
207
|
+
|
|
208
|
+
if decorated_count == 0:
|
|
209
|
+
typer.echo("⚠️ No pyseal decorators found in this file.")
|
|
210
|
+
elif valid_count == decorated_count:
|
|
211
|
+
typer.echo(typer.style("All decorators are valid in 1 file:", fg=typer.colors.BLUE, bold=True))
|
|
212
|
+
typer.echo(f"{typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}: {typer.style(f'{decorated_count} decorators valid', fg=typer.colors.GREEN)}")
|
|
213
|
+
else:
|
|
214
|
+
typer.echo(typer.style(f"{decorated_count - valid_count}/{decorated_count} decorators failed verification across 1 file:", fg=typer.colors.BLUE, bold=True), err=True)
|
|
215
|
+
typer.echo(f" {typer.style('✗', fg=typer.colors.RED)} {resolved_path}: {typer.style(f'{decorated_count - valid_count}/{decorated_count} decorators failed', fg=typer.colors.RED)}")
|
|
216
|
+
raise typer.Exit(code=1)
|
|
217
|
+
|
|
218
|
+
except (FileNotFoundError, NotADirectoryError, ValueError) as e:
|
|
219
|
+
typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
|
|
220
|
+
raise typer.Exit(code=1)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command()
|
|
224
|
+
def remove(
|
|
225
|
+
file_path: Annotated[
|
|
226
|
+
str,
|
|
227
|
+
typer.Argument(help="Path to the Python file or folder to remove pyseal decorators from")
|
|
228
|
+
]
|
|
229
|
+
):
|
|
230
|
+
"""Remove pyseal decorators from all functions and classes in a Python file or all Python files in a folder."""
|
|
231
|
+
path = Path(file_path)
|
|
232
|
+
|
|
233
|
+
# Validate path exists
|
|
234
|
+
if not path.exists():
|
|
235
|
+
typer.echo(typer.style(f"Error: Path '{path}' does not exist.", fg=typer.colors.RED, bold=True), err=True)
|
|
236
|
+
raise typer.Exit(code=1)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
# Handle folder path
|
|
240
|
+
if path.is_dir():
|
|
241
|
+
resolved_path = str(path.resolve())
|
|
242
|
+
modified_files = remove_decorators_from_folder(resolved_path)
|
|
243
|
+
|
|
244
|
+
if modified_files:
|
|
245
|
+
typer.echo(typer.style(f"Successfully removed decorators from {len(modified_files)} files:", fg=typer.colors.BLUE, bold=True))
|
|
246
|
+
for file in modified_files:
|
|
247
|
+
typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {file}")
|
|
248
|
+
else:
|
|
249
|
+
typer.echo("⚠️ No pyseal decorators found in any files.")
|
|
250
|
+
|
|
251
|
+
# Handle file path
|
|
252
|
+
else:
|
|
253
|
+
# Validate it's a Python file
|
|
254
|
+
if not path.suffix == '.py':
|
|
255
|
+
typer.echo(typer.style(f"Error: File '{path}' is not a Python file.", fg=typer.colors.RED, bold=True), err=True)
|
|
256
|
+
raise typer.Exit(code=1)
|
|
257
|
+
|
|
258
|
+
resolved_path = str(path.resolve())
|
|
259
|
+
modified_code, found = remove_decorators(resolved_path)
|
|
260
|
+
|
|
261
|
+
with open(resolved_path, 'w') as f:
|
|
262
|
+
f.write(modified_code)
|
|
263
|
+
|
|
264
|
+
if found:
|
|
265
|
+
typer.echo(typer.style(f"Successfully removed decorators from 1 file:", fg=typer.colors.BLUE, bold=True))
|
|
266
|
+
typer.echo(f" {typer.style('✓', fg=typer.colors.GREEN)} {resolved_path}")
|
|
267
|
+
else:
|
|
268
|
+
typer.echo(f"⚠️ No pyseal decorators found in {resolved_path}")
|
|
269
|
+
|
|
270
|
+
except (FileNotFoundError, NotADirectoryError, ValueError) as e:
|
|
271
|
+
typer.echo(typer.style(f"Error: {e}", fg=typer.colors.RED, bold=True), err=True)
|
|
272
|
+
raise typer.Exit(code=1)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
typer.echo(typer.style(f"Unexpected error while removing decorators: {e}", fg=typer.colors.RED, bold=True), err=True)
|
|
275
|
+
raise typer.Exit(code=1)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main():
|
|
279
|
+
"""Main CLI entry point."""
|
|
280
|
+
app()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == '__main__':
|
|
284
|
+
main()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Defines dummy decorators for all decorators found in the target file."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import ast
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _dummy_decorator(func=None, *args, **kwargs):
|
|
9
|
+
"""
|
|
10
|
+
A no-op (dummy) decorator that can be used in place of any decorator.
|
|
11
|
+
|
|
12
|
+
Handles both @deco and @deco(...) usages. If used as @deco, it returns the function unchanged.
|
|
13
|
+
If used as @deco(...), it returns a wrapper that returns the function unchanged.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
func (callable, optional): The function to decorate, or None if called with arguments.
|
|
17
|
+
*args: Positional arguments (ignored).
|
|
18
|
+
**kwargs: Keyword arguments (ignored).
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
callable: The original function, unchanged.
|
|
22
|
+
"""
|
|
23
|
+
if callable(func) and not args and not kwargs:
|
|
24
|
+
return func
|
|
25
|
+
def wrapper(f):
|
|
26
|
+
return f
|
|
27
|
+
return wrapper
|
|
28
|
+
|
|
29
|
+
def _discover_decorators(file_path):
|
|
30
|
+
"""
|
|
31
|
+
Yield all decorator names used in the given Python file.
|
|
32
|
+
|
|
33
|
+
This function parses the specified Python file and walks its AST to find all decorator names
|
|
34
|
+
used on functions, async functions, and classes. It handles decorators used as @deco, @deco(...),
|
|
35
|
+
and @obj.deco or @obj.deco(...).
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
file_path (str): Path to the Python file to scan.
|
|
39
|
+
|
|
40
|
+
Yields:
|
|
41
|
+
str: The name of each decorator found.
|
|
42
|
+
"""
|
|
43
|
+
if not os.path.exists(file_path):
|
|
44
|
+
return
|
|
45
|
+
with open(file_path, "r") as f:
|
|
46
|
+
src = f.read()
|
|
47
|
+
try:
|
|
48
|
+
tree = ast.parse(src)
|
|
49
|
+
except Exception:
|
|
50
|
+
return
|
|
51
|
+
for node in ast.walk(tree):
|
|
52
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
53
|
+
for deco in node.decorator_list:
|
|
54
|
+
# Handles @deco or @deco(...)
|
|
55
|
+
if isinstance(deco, ast.Name):
|
|
56
|
+
yield deco.id
|
|
57
|
+
elif isinstance(deco, ast.Attribute):
|
|
58
|
+
yield deco.attr
|
|
59
|
+
elif isinstance(deco.func, ast.Name):
|
|
60
|
+
yield deco.func.id
|
|
61
|
+
elif isinstance(deco.func, ast.Attribute):
|
|
62
|
+
yield deco.func.attr
|
|
63
|
+
|
|
64
|
+
def _get_caller_file():
|
|
65
|
+
"""
|
|
66
|
+
Find the filename of the module that imported this module at the top level.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
str or None: The filename of the caller module, or None if not found.
|
|
70
|
+
"""
|
|
71
|
+
stack = inspect.stack()
|
|
72
|
+
for frame in stack:
|
|
73
|
+
if frame.function == "<module>" and frame.filename != __file__:
|
|
74
|
+
return frame.filename
|
|
75
|
+
|
|
76
|
+
# Main logic: define dummy decorators for all found in the caller file
|
|
77
|
+
_CALLER_FILE = _get_caller_file()
|
|
78
|
+
if _CALLER_FILE:
|
|
79
|
+
_seen = set()
|
|
80
|
+
for deco_name in _discover_decorators(_CALLER_FILE):
|
|
81
|
+
if deco_name and deco_name not in globals() and deco_name not in _seen:
|
|
82
|
+
globals()[deco_name] = _dummy_decorator
|
|
83
|
+
_seen.add(deco_name)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Remove cryptographic pyseal 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 @pyseal.* 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_pyseal_decorator = False
|
|
28
|
+
if isinstance(decorator, ast.Name):
|
|
29
|
+
if decorator.id.startswith("pyseal"):
|
|
30
|
+
is_pyseal_decorator = True
|
|
31
|
+
elif isinstance(decorator, ast.Attribute):
|
|
32
|
+
if isinstance(decorator.value, ast.Name) and decorator.value.id == "pyseal":
|
|
33
|
+
is_pyseal_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 == "pyseal":
|
|
38
|
+
is_pyseal_decorator = True
|
|
39
|
+
elif isinstance(func, ast.Name) and func.id.startswith("pyseal"):
|
|
40
|
+
is_pyseal_decorator = True
|
|
41
|
+
if is_pyseal_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 pyseal 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
|
pyseal/setup.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Setup the storage of the pyseal 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 pyseal 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 PYSEAL_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 PYSEAL_ENV_PATH environment variable is set
|
|
22
|
+
env_path_var = os.getenv("PYSEAL_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("PYSEAL_PRIVATE_KEY")
|
|
58
|
+
existing_public = os.getenv("PYSEAL_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), "PYSEAL_PRIVATE_KEY", private_key_hex)
|
|
71
|
+
set_key(str(env_path), "PYSEAL_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("PYSEAL_PUBLIC_KEY")
|
|
101
|
+
|
|
102
|
+
if public_key is None:
|
|
103
|
+
raise ValueError(f"PYSEAL_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("PYSEAL_PRIVATE_KEY")
|
|
133
|
+
|
|
134
|
+
if private_key is None:
|
|
135
|
+
raise ValueError(f"PYSEAL_PRIVATE_KEY not found in {env_path}. Run setup_keypair() first.")
|
|
136
|
+
|
|
137
|
+
return private_key
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysealer
|
|
3
|
+
Version: 0.1.3
|
|
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: pytest>=7.0.0 ; extra == 'test'
|
|
21
|
+
Requires-Dist: pytest-cov>=4.0.0 ; extra == 'test'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.21.0 ; extra == 'test'
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Summary: Cryptographically sign Python functions and classes for defense-in-depth security
|
|
26
|
+
Keywords: rust,python,decorator,cryptography
|
|
27
|
+
Author: Aidan Dyga
|
|
28
|
+
License: MIT
|
|
29
|
+
Requires-Python: >=3.8
|
|
30
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
31
|
+
Project-URL: Issues, https://github.com/MCP-Security-Research/pysealer/issues
|
|
32
|
+
Project-URL: Repository, https://github.com/MCP-Security-Research/pysealer
|
|
33
|
+
|
|
34
|
+
# pyseal
|
|
35
|
+
|
|
36
|
+
Cryptographically sign your Python functions and classes to detect version control changes
|
|
37
|
+
|
|
38
|
+
> 💡 **code version controls code**
|
|
39
|
+
|
|
40
|
+
- 🦀 Built with the [maturin build system](https://www.maturin.rs/) for easy Rust-Python packaging
|
|
41
|
+
- 🔗 [PyO3](https://pyo3.rs/v0.27.1/index.html) bindings for seamless Python-Rust integration
|
|
42
|
+
- 🔏 [Ed25519](https://docs.rs/ed25519-dalek/latest/ed25519_dalek/) signatures to ensure code integrity and authorship
|
|
43
|
+
- 🖥️ [Typer](https://typer.tiangolo.com/) for a clean and user-friendly command line interface
|
|
44
|
+
|
|
45
|
+
Pyseal 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.
|
|
46
|
+
|
|
47
|
+
## Table of Contents
|
|
48
|
+
|
|
49
|
+
1. [Getting Started](#getting-started)
|
|
50
|
+
2. [Usage](#usage)
|
|
51
|
+
3. [How It Works](#how-it-works)
|
|
52
|
+
4. [Model Context Protocol (MCP) Security Use Cases](#model-context-protocol-mcp-security-use-cases)
|
|
53
|
+
5. [Contributing](#contributing)
|
|
54
|
+
6. [License](#license)
|
|
55
|
+
|
|
56
|
+
## Getting Started
|
|
57
|
+
|
|
58
|
+
```shell
|
|
59
|
+
pip install pyseal
|
|
60
|
+
# or
|
|
61
|
+
uv pip install pyseal
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```shell
|
|
67
|
+
pyseal init [ENV_FILE] # Initialize the pyseal tool by generating and saving keys to an ENV_FILE (default: .env)
|
|
68
|
+
pyseal decorate <file.py>... # Add cryptographic decorators to all functions/classes in one or more .py files
|
|
69
|
+
pyseal check <file.py>... # Verify the integrity and validity of pyseal decorators in one or more .py files
|
|
70
|
+
pyseal remove <file.py>... # Remove all pyseal decorators from one or more .py files
|
|
71
|
+
pyseal --help # Show all available commands and options
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## How It Works
|
|
75
|
+
|
|
76
|
+
Pyseal works by automatically injecting cryptographic decorators into your Python functions and classes. Here’s how the process works:
|
|
77
|
+
|
|
78
|
+
### Step-by-Step Example
|
|
79
|
+
|
|
80
|
+
Suppose you have a file `fibonacci.py`:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
def fibonacci(n):
|
|
84
|
+
if n <= 0:
|
|
85
|
+
return 0
|
|
86
|
+
elif n == 1:
|
|
87
|
+
return 1
|
|
88
|
+
else:
|
|
89
|
+
return fibonacci(n-1) + fibonacci(n-2)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### 1. Decorate the file
|
|
93
|
+
|
|
94
|
+
```shell
|
|
95
|
+
pyseal decorate examples/fibonacci.py
|
|
96
|
+
|
|
97
|
+
Successfully added decorators to 1 file:
|
|
98
|
+
✓ /path/to/examples/fibonacci.py
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
@pyseal._GnCLaWr9B6TD524JZ3v1CENXmo5Drwfgvc9arVagbghQ6hMH4Aqc8whs3Tf57pkTjsAVNDybviW9XG5Eu3JSP6T()
|
|
103
|
+
def fibonacci(n):
|
|
104
|
+
if n <= 0:
|
|
105
|
+
return 0
|
|
106
|
+
elif n == 1:
|
|
107
|
+
return 1
|
|
108
|
+
else:
|
|
109
|
+
return fibonacci(n-1) + fibonacci(n-2)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
#### 2. Check integrity
|
|
113
|
+
|
|
114
|
+
```shell
|
|
115
|
+
pyseal check examples/fibonacci.py
|
|
116
|
+
|
|
117
|
+
All decorators are valid in 1 file:
|
|
118
|
+
✓ /path/to/examples/fibonacci.py: 1 decorators valid
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### 3. Tamper with the code (change return 0 to return 42)
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@pyseal._GnCLaWr9B6TD524JZ3v1CENXmo5Drwfgvc9arVagbghQ6hMH4Aqc8whs3Tf57pkTjsAVNDybviW9XG5Eu3JSP6T()
|
|
125
|
+
def fibonacci(n):
|
|
126
|
+
if n <= 0:
|
|
127
|
+
return 42
|
|
128
|
+
elif n == 1:
|
|
129
|
+
return 1
|
|
130
|
+
else:
|
|
131
|
+
return fibonacci(n-1) + fibonacci(n-2)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### 4. Check again
|
|
135
|
+
|
|
136
|
+
```shell
|
|
137
|
+
pyseal check examples/fibonacci.py
|
|
138
|
+
|
|
139
|
+
1/1 decorators failed verification across 1 file:
|
|
140
|
+
✗ /path/to/examples/fibonacci.py: 1/1 decorators failed
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Model Context Protocol (MCP) Security Use Cases
|
|
144
|
+
|
|
145
|
+
One use case of Pyseal 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. Pyseal's signatures ensure tool authenticity and detect tampering because any modification to code or docstrings breaks the signature and flags compromised tools.
|
|
146
|
+
|
|
147
|
+
- **Detect Version Control Changes**
|
|
148
|
+
- Automatically detect unauthorized code modifications through cryptographic signatures
|
|
149
|
+
- Each function's decorator contains a signature based on its code and docstring
|
|
150
|
+
- Any mismatch between code and signature is immediately flagged
|
|
151
|
+
|
|
152
|
+
- **Defense-in-Depth for Source Control**
|
|
153
|
+
- Add an additional security layer to version control systems
|
|
154
|
+
- Complement existing security measures with cryptographic verification
|
|
155
|
+
- Reduce risk through multiple layers of protection
|
|
156
|
+
|
|
157
|
+
## Contributing
|
|
158
|
+
|
|
159
|
+
**🙌 Contributions are welcome!**
|
|
160
|
+
|
|
161
|
+
If you have suggestions, bug reports, or want to help improve Pyseal, feel free to open an [issue](https://github.com/MCP-Security-Research/pyseal/issues) or submit a pull request.
|
|
162
|
+
|
|
163
|
+
All ideas and contributions are appreciated—thanks for helping make pyseal better!
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
Pyseal is licensed under the MIT License. See [LICENSE](LICENSE) for details.
|
|
168
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pyseal/__init__.py,sha256=UYa_3JkAojYCceftDG7L_SuaYG9qzXqPeaccBA-enlA,934
|
|
2
|
+
pyseal/_pyseal.cpython-313t-powerpc64le-linux-gnu.so,sha256=wB2QMid9kEfx6z3Xn3J5zjss5uY-NH9BB6_mud8tfEQ,1041592
|
|
3
|
+
pyseal/add_decorators.py,sha256=NOuSN9feAJ8jlKVrLtp6IhY4SBgRtye7qbwx1HDgiYE,8696
|
|
4
|
+
pyseal/check_decorators.py,sha256=17KjDxuhFy23o53sKO3LP_KuR2AaDOSkz4BERGCwgB8,7155
|
|
5
|
+
pyseal/cli.py,sha256=Nvh51PiFUvEGxh1pwgPsTTSmiGLclv72sE-CiRmiFEk,12000
|
|
6
|
+
pyseal/dummy_decorators.py,sha256=9ORNnMZ6lT5e1gU4Dt9lifAEDfjJ1wCXGiOUfMiqP-w,2807
|
|
7
|
+
pyseal/remove_decorators.py,sha256=iE-8tj4y14d4GH26c7PTqLbdWxlTw91wL4f88_foxSE,3250
|
|
8
|
+
pyseal/setup.py,sha256=n8CNHqx0dtsAsbjuMZWSjQAfI9hfXap0SqoifdDZm74,4180
|
|
9
|
+
pysealer-0.1.3.dist-info/METADATA,sha256=XPNM9GcmC9bJLmqeynnw9cYozgSBELcuKKfy_1gCKYA,6100
|
|
10
|
+
pysealer-0.1.3.dist-info/WHEEL,sha256=2yo4KOUoBa6tMWvxk21okE8Ycb5W22kL8Ukm23Q3bcA,151
|
|
11
|
+
pysealer-0.1.3.dist-info/entry_points.txt,sha256=OAG9fOMEkDabuZBWEMJ6KqhDfBdNRadOntLkG05qjkg,41
|
|
12
|
+
pysealer-0.1.3.dist-info/licenses/LICENSE,sha256=FtcYsMyXNwAqNhOMxR3TwptCUnwb5coRK_UQyWGLJnc,1067
|
|
13
|
+
pysealer-0.1.3.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.
|