zexus 1.6.2 → 1.6.3
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.
- package/README.md +165 -5
- package/package.json +1 -1
- package/src/zexus/__init__.py +1 -1
- package/src/zexus/access_control_system/__init__.py +38 -0
- package/src/zexus/access_control_system/access_control.py +237 -0
- package/src/zexus/cli/main.py +1 -1
- package/src/zexus/cli/zpm.py +1 -1
- package/src/zexus/debug_sanitizer.py +250 -0
- package/src/zexus/error_reporter.py +22 -2
- package/src/zexus/evaluator/core.py +7 -2
- package/src/zexus/evaluator/expressions.py +116 -57
- package/src/zexus/evaluator/functions.py +586 -170
- package/src/zexus/evaluator/resource_limiter.py +291 -0
- package/src/zexus/evaluator/statements.py +31 -3
- package/src/zexus/evaluator/utils.py +12 -6
- package/src/zexus/lsp/server.py +1 -1
- package/src/zexus/object.py +21 -2
- package/src/zexus/parser/parser.py +39 -1
- package/src/zexus/parser/strategy_context.py +29 -4
- package/src/zexus/parser/strategy_structural.py +12 -4
- package/src/zexus/persistence.py +105 -6
- package/src/zexus/security_enforcement.py +237 -0
- package/src/zexus/stdlib/fs.py +120 -22
- package/src/zexus/zpm/package_manager.py +1 -1
- package/src/zexus.egg-info/PKG-INFO +499 -13
- package/src/zexus.egg-info/SOURCES.txt +242 -152
package/src/zexus/persistence.py
CHANGED
|
@@ -128,15 +128,35 @@ def disable_memory_tracking():
|
|
|
128
128
|
# PERSISTENT STORAGE BACKEND
|
|
129
129
|
# ===============================================
|
|
130
130
|
|
|
131
|
+
class StorageLimitError(Exception):
|
|
132
|
+
"""Raised when persistent storage limits are exceeded"""
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
131
136
|
class PersistentStorage:
|
|
132
|
-
"""Persistent storage for variables using SQLite"""
|
|
137
|
+
"""Persistent storage for variables using SQLite with size limits"""
|
|
133
138
|
|
|
134
|
-
|
|
139
|
+
# Default limits (configurable)
|
|
140
|
+
DEFAULT_MAX_ITEMS = 10000 # Maximum number of stored variables
|
|
141
|
+
DEFAULT_MAX_SIZE_MB = 100 # Maximum storage size in MB
|
|
142
|
+
|
|
143
|
+
def __init__(self, scope_id: str, storage_dir: str = PERSISTENCE_DIR,
|
|
144
|
+
max_items: int = None, max_size_mb: int = None):
|
|
135
145
|
self.scope_id = scope_id
|
|
136
146
|
self.db_path = os.path.join(storage_dir, f"{scope_id}.sqlite")
|
|
137
147
|
self.conn = None
|
|
138
148
|
self.lock = Lock()
|
|
149
|
+
|
|
150
|
+
# Storage limits
|
|
151
|
+
self.max_items = max_items if max_items is not None else self.DEFAULT_MAX_ITEMS
|
|
152
|
+
self.max_size_bytes = (max_size_mb if max_size_mb is not None else self.DEFAULT_MAX_SIZE_MB) * 1024 * 1024
|
|
153
|
+
|
|
154
|
+
# Usage tracking
|
|
155
|
+
self.current_item_count = 0
|
|
156
|
+
self.current_size_bytes = 0
|
|
157
|
+
|
|
139
158
|
self._init_db()
|
|
159
|
+
self._update_usage_stats()
|
|
140
160
|
|
|
141
161
|
def _init_db(self):
|
|
142
162
|
"""Initialize SQLite database"""
|
|
@@ -147,6 +167,7 @@ class PersistentStorage:
|
|
|
147
167
|
name TEXT PRIMARY KEY,
|
|
148
168
|
type TEXT NOT NULL,
|
|
149
169
|
value TEXT NOT NULL,
|
|
170
|
+
size_bytes INTEGER DEFAULT 0,
|
|
150
171
|
is_const INTEGER DEFAULT 0,
|
|
151
172
|
created_at REAL NOT NULL,
|
|
152
173
|
updated_at REAL NOT NULL
|
|
@@ -157,24 +178,96 @@ class PersistentStorage:
|
|
|
157
178
|
''')
|
|
158
179
|
self.conn.commit()
|
|
159
180
|
|
|
181
|
+
def _update_usage_stats(self):
|
|
182
|
+
"""Update current usage statistics"""
|
|
183
|
+
with self.lock:
|
|
184
|
+
cursor = self.conn.cursor()
|
|
185
|
+
|
|
186
|
+
# Count items
|
|
187
|
+
cursor.execute('SELECT COUNT(*) FROM variables')
|
|
188
|
+
self.current_item_count = cursor.fetchone()[0]
|
|
189
|
+
|
|
190
|
+
# Calculate total size
|
|
191
|
+
cursor.execute('SELECT SUM(size_bytes) FROM variables')
|
|
192
|
+
result = cursor.fetchone()[0]
|
|
193
|
+
self.current_size_bytes = result if result else 0
|
|
194
|
+
|
|
195
|
+
def _calculate_size(self, serialized: Dict[str, str]) -> int:
|
|
196
|
+
"""Calculate size of serialized data in bytes"""
|
|
197
|
+
# Approximate size: length of type + value strings
|
|
198
|
+
size = len(serialized['type']) + len(serialized['value'])
|
|
199
|
+
return size
|
|
200
|
+
|
|
201
|
+
def _check_limits(self, name: str, new_size: int) -> None:
|
|
202
|
+
"""Check if adding/updating a variable would exceed limits"""
|
|
203
|
+
# Get current size of existing variable if it exists
|
|
204
|
+
cursor = self.conn.cursor()
|
|
205
|
+
cursor.execute('SELECT size_bytes FROM variables WHERE name = ?', (name,))
|
|
206
|
+
row = cursor.fetchone()
|
|
207
|
+
existing_size = row[0] if row else 0
|
|
208
|
+
is_update = row is not None
|
|
209
|
+
|
|
210
|
+
# Calculate new totals
|
|
211
|
+
new_item_count = self.current_item_count if is_update else self.current_item_count + 1
|
|
212
|
+
new_total_size = self.current_size_bytes - existing_size + new_size
|
|
213
|
+
|
|
214
|
+
# Check item limit
|
|
215
|
+
if new_item_count > self.max_items:
|
|
216
|
+
raise StorageLimitError(
|
|
217
|
+
f"Persistent storage item limit exceeded: {new_item_count} > {self.max_items}. "
|
|
218
|
+
f"Cannot store variable '{name}'. "
|
|
219
|
+
f"Consider increasing max_items or cleaning up old variables."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Check size limit
|
|
223
|
+
if new_total_size > self.max_size_bytes:
|
|
224
|
+
size_mb = new_total_size / (1024 * 1024)
|
|
225
|
+
limit_mb = self.max_size_bytes / (1024 * 1024)
|
|
226
|
+
raise StorageLimitError(
|
|
227
|
+
f"Persistent storage size limit exceeded: {size_mb:.2f}MB > {limit_mb:.2f}MB. "
|
|
228
|
+
f"Cannot store variable '{name}' ({new_size} bytes). "
|
|
229
|
+
f"Consider increasing max_size_mb or cleaning up old data."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def get_usage_stats(self) -> Dict[str, Any]:
|
|
233
|
+
"""Get current storage usage statistics"""
|
|
234
|
+
return {
|
|
235
|
+
'item_count': self.current_item_count,
|
|
236
|
+
'max_items': self.max_items,
|
|
237
|
+
'items_remaining': self.max_items - self.current_item_count,
|
|
238
|
+
'size_bytes': self.current_size_bytes,
|
|
239
|
+
'size_mb': self.current_size_bytes / (1024 * 1024),
|
|
240
|
+
'max_size_mb': self.max_size_bytes / (1024 * 1024),
|
|
241
|
+
'size_remaining_mb': (self.max_size_bytes - self.current_size_bytes) / (1024 * 1024),
|
|
242
|
+
'usage_percent': (self.current_size_bytes / self.max_size_bytes * 100) if self.max_size_bytes > 0 else 0
|
|
243
|
+
}
|
|
244
|
+
|
|
160
245
|
def set(self, name: str, value: Object, is_const: bool = False):
|
|
161
|
-
"""Persist a variable"""
|
|
246
|
+
"""Persist a variable with size limit checks"""
|
|
162
247
|
with self.lock:
|
|
163
248
|
serialized = self._serialize(value)
|
|
249
|
+
size_bytes = self._calculate_size(serialized)
|
|
250
|
+
|
|
251
|
+
# Check limits before storing
|
|
252
|
+
self._check_limits(name, size_bytes)
|
|
253
|
+
|
|
164
254
|
cursor = self.conn.cursor()
|
|
165
255
|
|
|
166
256
|
import time
|
|
167
257
|
timestamp = time.time()
|
|
168
258
|
|
|
169
259
|
cursor.execute('''
|
|
170
|
-
INSERT OR REPLACE INTO variables (name, type, value, is_const, created_at, updated_at)
|
|
171
|
-
VALUES (?, ?, ?, ?,
|
|
260
|
+
INSERT OR REPLACE INTO variables (name, type, value, size_bytes, is_const, created_at, updated_at)
|
|
261
|
+
VALUES (?, ?, ?, ?, ?,
|
|
172
262
|
COALESCE((SELECT created_at FROM variables WHERE name = ?), ?),
|
|
173
263
|
?)
|
|
174
|
-
''', (name, serialized['type'], serialized['value'], 1 if is_const else 0,
|
|
264
|
+
''', (name, serialized['type'], serialized['value'], size_bytes, 1 if is_const else 0,
|
|
175
265
|
name, timestamp, timestamp))
|
|
176
266
|
|
|
177
267
|
self.conn.commit()
|
|
268
|
+
|
|
269
|
+
# Update usage stats
|
|
270
|
+
self._update_usage_stats()
|
|
178
271
|
|
|
179
272
|
def get(self, name: str) -> Optional[Object]:
|
|
180
273
|
"""Retrieve a persisted variable"""
|
|
@@ -186,6 +279,9 @@ class PersistentStorage:
|
|
|
186
279
|
if row is None:
|
|
187
280
|
return None
|
|
188
281
|
|
|
282
|
+
|
|
283
|
+
# Update usage stats
|
|
284
|
+
self._update_usage_stats()
|
|
189
285
|
return self._deserialize({'type': row[0], 'value': row[1]})
|
|
190
286
|
|
|
191
287
|
def delete(self, name: str):
|
|
@@ -213,6 +309,9 @@ class PersistentStorage:
|
|
|
213
309
|
def clear(self):
|
|
214
310
|
"""Clear all persisted variables"""
|
|
215
311
|
with self.lock:
|
|
312
|
+
|
|
313
|
+
# Update usage stats
|
|
314
|
+
self._update_usage_stats()
|
|
216
315
|
cursor = self.conn.cursor()
|
|
217
316
|
cursor.execute('DELETE FROM variables')
|
|
218
317
|
self.conn.commit()
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# src/zexus/security_enforcement.py
|
|
2
|
+
"""
|
|
3
|
+
Security enforcement for Zexus language.
|
|
4
|
+
|
|
5
|
+
This module enforces mandatory sanitization in sensitive contexts.
|
|
6
|
+
It's NOT optional - security is built into the language.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .object import String, EvaluationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SecurityEnforcementError(Exception):
|
|
13
|
+
"""Raised when unsanitized input is used in sensitive context"""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SensitiveContext:
|
|
18
|
+
"""Defines sensitive contexts that require sanitization"""
|
|
19
|
+
|
|
20
|
+
SQL = 'sql'
|
|
21
|
+
HTML = 'html'
|
|
22
|
+
URL = 'url'
|
|
23
|
+
SHELL = 'shell'
|
|
24
|
+
|
|
25
|
+
# Patterns that indicate SQL context
|
|
26
|
+
SQL_PATTERNS = [
|
|
27
|
+
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE',
|
|
28
|
+
'ALTER', 'FROM', 'WHERE', 'JOIN', 'UNION'
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Patterns that indicate HTML context
|
|
32
|
+
HTML_PATTERNS = [
|
|
33
|
+
'<html', '<div', '<span', '<script', '<body', '<head',
|
|
34
|
+
'innerHTML', 'outerHTML'
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Patterns that indicate URL context
|
|
38
|
+
URL_PATTERNS = [
|
|
39
|
+
'http://', 'https://', 'ftp://', '?', '&', 'url=', 'redirect='
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Patterns that indicate shell context
|
|
43
|
+
SHELL_PATTERNS = [
|
|
44
|
+
'exec', 'system', 'shell', 'bash', 'sh', 'cmd', 'powershell'
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_sensitive_context(string_value):
|
|
49
|
+
"""
|
|
50
|
+
Detect if a string is being used in a sensitive context.
|
|
51
|
+
|
|
52
|
+
Returns the context type (sql, html, url, shell) or None.
|
|
53
|
+
|
|
54
|
+
IMPORTANT: This now uses more sophisticated pattern matching to reduce
|
|
55
|
+
false positives. We look for actual dangerous patterns, not just keywords.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(string_value, str):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
upper_value = string_value.upper()
|
|
61
|
+
|
|
62
|
+
# Check for SQL context - require actual SQL query patterns, not just keywords
|
|
63
|
+
# Look for patterns like "SELECT ... FROM", "WHERE ... =", etc.
|
|
64
|
+
sql_query_indicators = [
|
|
65
|
+
('SELECT', 'FROM'), # SELECT must be followed by FROM
|
|
66
|
+
('INSERT', 'INTO'), # INSERT must be followed by INTO
|
|
67
|
+
('UPDATE', 'SET'), # UPDATE must be followed by SET
|
|
68
|
+
('DELETE', 'FROM'), # DELETE must be followed by FROM
|
|
69
|
+
('DROP', 'TABLE'), # DROP must be followed by TABLE
|
|
70
|
+
('CREATE', 'TABLE'), # CREATE must be followed by TABLE
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for keyword1, keyword2 in sql_query_indicators:
|
|
74
|
+
if keyword1 in upper_value and keyword2 in upper_value:
|
|
75
|
+
# Found a real SQL query pattern
|
|
76
|
+
return SensitiveContext.SQL
|
|
77
|
+
|
|
78
|
+
# Single keywords alone are not enough - they could be normal text
|
|
79
|
+
# Only trigger if we see SQL-like syntax patterns
|
|
80
|
+
if ' WHERE ' in upper_value and ('=' in string_value or 'LIKE' in upper_value):
|
|
81
|
+
return SensitiveContext.SQL
|
|
82
|
+
|
|
83
|
+
# Check for HTML context - require actual HTML tags, not just keywords
|
|
84
|
+
for pattern in SensitiveContext.HTML_PATTERNS:
|
|
85
|
+
if pattern.lower() in string_value.lower():
|
|
86
|
+
# Check if it's actually a tag (starts with <)
|
|
87
|
+
if pattern.startswith('<') or 'innerHTML' in string_value or 'outerHTML' in string_value:
|
|
88
|
+
return SensitiveContext.HTML
|
|
89
|
+
|
|
90
|
+
# Check for URL context - require actual URL schemes or injection patterns
|
|
91
|
+
url_indicators = ['http://', 'https://', 'ftp://']
|
|
92
|
+
injection_indicators = ['url=', 'redirect=', 'goto=', 'next=']
|
|
93
|
+
|
|
94
|
+
has_url_scheme = any(indicator in string_value.lower() for indicator in url_indicators)
|
|
95
|
+
has_injection_param = any(indicator in string_value.lower() for indicator in injection_indicators)
|
|
96
|
+
|
|
97
|
+
if has_url_scheme or (has_injection_param and ('?' in string_value or '&' in string_value)):
|
|
98
|
+
return SensitiveContext.URL
|
|
99
|
+
|
|
100
|
+
# Check for shell context - require actual command execution patterns
|
|
101
|
+
shell_execution_funcs = ['exec(', 'system(', 'shell(', 'bash ', 'sh ', 'cmd ', 'powershell ']
|
|
102
|
+
if any(pattern in string_value.lower() for pattern in shell_execution_funcs):
|
|
103
|
+
return SensitiveContext.SHELL
|
|
104
|
+
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def enforce_sanitization(string_obj, operation_context=None):
|
|
109
|
+
"""
|
|
110
|
+
Enforce sanitization requirement for String objects in sensitive contexts.
|
|
111
|
+
|
|
112
|
+
This is ALWAYS enforced - not optional. Security is built into the language.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
string_obj: The String object to check
|
|
116
|
+
operation_context: Optional explicit context (sql, html, url, shell)
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
EvaluationError: If unsanitized input is used in sensitive context
|
|
120
|
+
"""
|
|
121
|
+
if not isinstance(string_obj, String):
|
|
122
|
+
return # Not a string, nothing to enforce
|
|
123
|
+
|
|
124
|
+
# If string is trusted (literal), no enforcement needed
|
|
125
|
+
if string_obj.is_trusted:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# Detect context if not explicitly provided
|
|
129
|
+
if operation_context is None:
|
|
130
|
+
operation_context = detect_sensitive_context(string_obj.value)
|
|
131
|
+
|
|
132
|
+
# If no sensitive context detected, allow
|
|
133
|
+
if operation_context is None:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
# Check if string is sanitized for this context
|
|
137
|
+
if not string_obj.is_safe_for(operation_context):
|
|
138
|
+
raise_sanitization_error(string_obj, operation_context)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def raise_sanitization_error(string_obj, context):
|
|
142
|
+
"""
|
|
143
|
+
Raise a clear, helpful error message for unsanitized input.
|
|
144
|
+
|
|
145
|
+
The error message guides developers to use the sanitize keyword.
|
|
146
|
+
"""
|
|
147
|
+
context_name = context.upper()
|
|
148
|
+
|
|
149
|
+
# Create helpful error message
|
|
150
|
+
error_msg = f"""
|
|
151
|
+
🔒 SECURITY ERROR: Unsanitized input used in {context_name} context
|
|
152
|
+
|
|
153
|
+
The string value appears to be used in a {context_name} operation, but it has not been sanitized.
|
|
154
|
+
This could lead to {get_vulnerability_name(context)} vulnerabilities.
|
|
155
|
+
|
|
156
|
+
To fix this, sanitize the input before use:
|
|
157
|
+
|
|
158
|
+
sanitize your_variable as {context}
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
|
|
162
|
+
❌ UNSAFE:
|
|
163
|
+
query = "SELECT * FROM users WHERE name = '" + user_input + "'"
|
|
164
|
+
|
|
165
|
+
✅ SAFE:
|
|
166
|
+
sanitize user_input as {context}
|
|
167
|
+
query = "SELECT * FROM users WHERE name = '" + user_input + "'"
|
|
168
|
+
|
|
169
|
+
Security is mandatory in Zexus - this protection cannot be disabled.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
raise SecurityEnforcementError(error_msg.strip())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_vulnerability_name(context):
|
|
176
|
+
"""Get the vulnerability name for a given context"""
|
|
177
|
+
vuln_map = {
|
|
178
|
+
SensitiveContext.SQL: "SQL Injection",
|
|
179
|
+
SensitiveContext.HTML: "Cross-Site Scripting (XSS)",
|
|
180
|
+
SensitiveContext.URL: "URL Injection / Open Redirect",
|
|
181
|
+
SensitiveContext.SHELL: "Command Injection"
|
|
182
|
+
}
|
|
183
|
+
return vuln_map.get(context, "Injection")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def check_string_concatenation(left, right):
|
|
187
|
+
"""
|
|
188
|
+
Check string concatenation for security issues.
|
|
189
|
+
|
|
190
|
+
When concatenating strings, if the result would be used in a sensitive
|
|
191
|
+
context, both operands must be sanitized or trusted.
|
|
192
|
+
|
|
193
|
+
Improvements:
|
|
194
|
+
- If BOTH operands are trusted (literals), the result is safe
|
|
195
|
+
- Only check context on the final combined result
|
|
196
|
+
- Reduce false positives from normal text operations
|
|
197
|
+
"""
|
|
198
|
+
# If either operand is a String object, check sanitization
|
|
199
|
+
left_is_string = isinstance(left, String)
|
|
200
|
+
right_is_string = isinstance(right, String)
|
|
201
|
+
|
|
202
|
+
if not (left_is_string or right_is_string):
|
|
203
|
+
return # Not string concatenation
|
|
204
|
+
|
|
205
|
+
# OPTIMIZATION: If both are trusted literals, the concatenation is safe
|
|
206
|
+
if (left_is_string and left.is_trusted) and (right_is_string and right.is_trusted):
|
|
207
|
+
return # Both sides are literals - safe!
|
|
208
|
+
|
|
209
|
+
# Get the concatenated value for context detection
|
|
210
|
+
left_val = left.value if left_is_string else str(left.inspect() if hasattr(left, 'inspect') else left)
|
|
211
|
+
right_val = right.value if right_is_string else str(right.inspect() if hasattr(right, 'inspect') else right)
|
|
212
|
+
combined = left_val + right_val
|
|
213
|
+
|
|
214
|
+
# Detect if the combined string is in a sensitive context
|
|
215
|
+
context = detect_sensitive_context(combined)
|
|
216
|
+
|
|
217
|
+
if context is None:
|
|
218
|
+
return # No sensitive context detected
|
|
219
|
+
|
|
220
|
+
# Check if both operands are safe for this context
|
|
221
|
+
# NOTE: We only enforce if the string is NOT trusted AND NOT sanitized
|
|
222
|
+
if left_is_string and not left.is_trusted and not left.is_safe_for(context):
|
|
223
|
+
enforce_sanitization(left, context)
|
|
224
|
+
|
|
225
|
+
if right_is_string and not right.is_trusted and not right.is_safe_for(context):
|
|
226
|
+
enforce_sanitization(right, context)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def mark_as_trusted(string_obj):
|
|
230
|
+
"""
|
|
231
|
+
Mark a string as trusted (from literal, not external input).
|
|
232
|
+
|
|
233
|
+
This should be called when creating String objects from literals.
|
|
234
|
+
"""
|
|
235
|
+
if isinstance(string_obj, String):
|
|
236
|
+
string_obj.is_trusted = True
|
|
237
|
+
return string_obj
|
package/src/zexus/stdlib/fs.py
CHANGED
|
@@ -4,76 +4,173 @@ import os
|
|
|
4
4
|
import shutil
|
|
5
5
|
import glob as glob_module
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import List, Dict, Any
|
|
7
|
+
from typing import List, Dict, Any, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PathTraversalError(Exception):
|
|
11
|
+
"""Raised when path traversal attack is detected."""
|
|
12
|
+
pass
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
class FileSystemModule:
|
|
11
|
-
"""Provides file system operations."""
|
|
16
|
+
"""Provides file system operations with path traversal protection."""
|
|
17
|
+
|
|
18
|
+
# Allowed base directories for file operations
|
|
19
|
+
# If None, uses current working directory
|
|
20
|
+
_allowed_base_dirs: Optional[List[str]] = None
|
|
21
|
+
_strict_mode: bool = True # Enable path validation by default
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def configure_security(cls, allowed_dirs: Optional[List[str]] = None, strict: bool = True):
|
|
25
|
+
"""
|
|
26
|
+
Configure file system security settings.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
allowed_dirs: List of allowed base directories. None = use CWD only.
|
|
30
|
+
strict: Enable strict path validation
|
|
31
|
+
"""
|
|
32
|
+
cls._allowed_base_dirs = allowed_dirs
|
|
33
|
+
cls._strict_mode = strict
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def _validate_path(cls, path: str, operation: str = "access") -> str:
|
|
37
|
+
"""
|
|
38
|
+
Validate path to prevent traversal attacks.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
path: User-provided path
|
|
42
|
+
operation: Type of operation (for error messages)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Validated absolute path
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
PathTraversalError: If path traversal detected
|
|
49
|
+
"""
|
|
50
|
+
if not cls._strict_mode:
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
# Convert to absolute path
|
|
54
|
+
abs_path = Path(path).resolve()
|
|
55
|
+
|
|
56
|
+
# Check for common traversal patterns
|
|
57
|
+
path_str = str(path)
|
|
58
|
+
if '..' in path_str:
|
|
59
|
+
# Allow .. only if it doesn't escape allowed directories
|
|
60
|
+
pass # Will be checked below
|
|
61
|
+
|
|
62
|
+
# Determine allowed base directories
|
|
63
|
+
if cls._allowed_base_dirs is None:
|
|
64
|
+
# Default: only allow access within CWD
|
|
65
|
+
allowed_bases = [Path.cwd().resolve()]
|
|
66
|
+
else:
|
|
67
|
+
allowed_bases = [Path(d).resolve() for d in cls._allowed_base_dirs]
|
|
68
|
+
|
|
69
|
+
# Check if resolved path is within allowed directories
|
|
70
|
+
is_allowed = False
|
|
71
|
+
for base in allowed_bases:
|
|
72
|
+
try:
|
|
73
|
+
abs_path.relative_to(base)
|
|
74
|
+
is_allowed = True
|
|
75
|
+
break
|
|
76
|
+
except ValueError:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if not is_allowed:
|
|
80
|
+
raise PathTraversalError(
|
|
81
|
+
f"Path traversal detected: '{path}' resolves to '{abs_path}' "
|
|
82
|
+
f"which is outside allowed directories. "
|
|
83
|
+
f"Allowed: {[str(b) for b in allowed_bases]}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return str(abs_path)
|
|
12
87
|
|
|
13
88
|
@staticmethod
|
|
14
89
|
def read_file(path: str, encoding: str = 'utf-8') -> str:
|
|
15
90
|
"""Read entire file as text."""
|
|
16
|
-
|
|
91
|
+
validated_path = FileSystemModule._validate_path(path, "read")
|
|
92
|
+
with open(validated_path, 'r', encoding=encoding) as f:
|
|
17
93
|
return f.read()
|
|
18
94
|
|
|
19
95
|
@staticmethod
|
|
20
96
|
def write_file(path: str, content: str, encoding: str = 'utf-8') -> None:
|
|
21
97
|
"""Write text to file."""
|
|
98
|
+
validated_path = FileSystemModule._validate_path(path, "write")
|
|
22
99
|
# Create parent directory if it doesn't exist
|
|
23
|
-
Path(
|
|
24
|
-
with open(
|
|
100
|
+
Path(validated_path).parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
with open(validated_path, 'w', encoding=encoding) as f:
|
|
25
102
|
f.write(content)
|
|
26
103
|
|
|
27
104
|
@staticmethod
|
|
28
105
|
def append_file(path: str, content: str, encoding: str = 'utf-8') -> None:
|
|
29
106
|
"""Append text to file."""
|
|
107
|
+
validated_path = FileSystemModule._validate_path(path, "append")
|
|
30
108
|
# Create parent directory if it doesn't exist (for consistency with write_file)
|
|
31
|
-
Path(
|
|
32
|
-
with open(
|
|
109
|
+
Path(validated_path).parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
with open(validated_path, 'a', encoding=encoding) as f:
|
|
33
111
|
f.write(content)
|
|
34
112
|
|
|
35
113
|
@staticmethod
|
|
36
114
|
def read_binary(path: str) -> bytes:
|
|
37
115
|
"""Read file as binary."""
|
|
38
|
-
|
|
116
|
+
validated_path = FileSystemModule._validate_path(path, "read_binary")
|
|
117
|
+
with open(validated_path, 'rb') as f:
|
|
39
118
|
return f.read()
|
|
40
119
|
|
|
41
120
|
@staticmethod
|
|
42
121
|
def write_binary(path: str, data: bytes) -> None:
|
|
43
122
|
"""Write binary data to file."""
|
|
44
|
-
|
|
45
|
-
|
|
123
|
+
validated_path = FileSystemModule._validate_path(path, "write_binary")
|
|
124
|
+
Path(validated_path).parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
with open(validated_path, 'wb') as f:
|
|
46
126
|
f.write(data)
|
|
47
127
|
|
|
48
128
|
@staticmethod
|
|
49
129
|
def exists(path: str) -> bool:
|
|
50
130
|
"""Check if file or directory exists."""
|
|
51
|
-
|
|
131
|
+
try:
|
|
132
|
+
validated_path = FileSystemModule._validate_path(path, "exists")
|
|
133
|
+
return os.path.exists(validated_path)
|
|
134
|
+
except PathTraversalError:
|
|
135
|
+
return False # Return False for invalid paths instead of error
|
|
52
136
|
|
|
53
137
|
@staticmethod
|
|
54
138
|
def is_file(path: str) -> bool:
|
|
55
139
|
"""Check if path is a file."""
|
|
56
|
-
|
|
140
|
+
try:
|
|
141
|
+
validated_path = FileSystemModule._validate_path(path, "is_file")
|
|
142
|
+
return os.path.isfile(validated_path)
|
|
143
|
+
except PathTraversalError:
|
|
144
|
+
return False
|
|
57
145
|
|
|
58
146
|
@staticmethod
|
|
59
147
|
def is_dir(path: str) -> bool:
|
|
60
148
|
"""Check if path is a directory."""
|
|
61
|
-
|
|
149
|
+
try:
|
|
150
|
+
validated_path = FileSystemModule._validate_path(path, "is_dir")
|
|
151
|
+
return os.path.isdir(validated_path)
|
|
152
|
+
except PathTraversalError:
|
|
153
|
+
return False
|
|
62
154
|
|
|
63
155
|
@staticmethod
|
|
64
156
|
def mkdir(path: str, parents: bool = True) -> None:
|
|
65
|
-
|
|
66
|
-
|
|
157
|
+
validated_path = FileSystemModule._validate_path(path, "remove")
|
|
158
|
+
os.remove(validated_path)
|
|
67
159
|
|
|
68
160
|
@staticmethod
|
|
69
|
-
def
|
|
70
|
-
"""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
os.rmdir(path)
|
|
161
|
+
def rename(old_path: str, new_path: str) -> None:
|
|
162
|
+
"""Rename/move file or directory."""
|
|
163
|
+
validated_old = FileSystemModule._validate_path(old_path, "rename_source")
|
|
164
|
+
validated_new = FileSystemModule._validate_path(new_path, "rename_dest")
|
|
165
|
+
os.rename(validated_old, validated_new)
|
|
75
166
|
|
|
76
167
|
@staticmethod
|
|
168
|
+
def copy_file(src: str, dst: str) -> None:
|
|
169
|
+
"""Copy file."""
|
|
170
|
+
validated_src = FileSystemModule._validate_path(src, "copy_source")
|
|
171
|
+
validated_dst = FileSystemModule._validate_path(dst, "copy_dest")
|
|
172
|
+
shutil.copy2(validated_src, validated_
|
|
173
|
+
@staticmethod
|
|
77
174
|
def remove(path: str) -> None:
|
|
78
175
|
"""Remove file."""
|
|
79
176
|
os.remove(path)
|
|
@@ -91,7 +188,8 @@ class FileSystemModule:
|
|
|
91
188
|
@staticmethod
|
|
92
189
|
def copy_dir(src: str, dst: str) -> None:
|
|
93
190
|
"""Copy directory recursively."""
|
|
94
|
-
|
|
191
|
+
validated_path = FileSystemModule._validate_path(path, "list_dir")
|
|
192
|
+
return os.listdir(validated_c, dst)
|
|
95
193
|
|
|
96
194
|
@staticmethod
|
|
97
195
|
def list_dir(path: str = '.') -> List[str]:
|
|
@@ -23,7 +23,7 @@ class PackageManager:
|
|
|
23
23
|
self.installer = PackageInstaller(self.zpm_dir)
|
|
24
24
|
self.publisher = PackagePublisher(self.registry)
|
|
25
25
|
|
|
26
|
-
def init(self, name: str = None, version: str = "1.6.
|
|
26
|
+
def init(self, name: str = None, version: str = "1.6.3") -> Dict:
|
|
27
27
|
"""Initialize a new Zexus project with package.json"""
|
|
28
28
|
if self.config_file.exists():
|
|
29
29
|
print(f"⚠️ {self.config_file} already exists")
|