zexus 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -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
- with open(path, 'r', encoding=encoding) as f:
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(path).parent.mkdir(parents=True, exist_ok=True)
24
- with open(path, 'w', encoding=encoding) as f:
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(path).parent.mkdir(parents=True, exist_ok=True)
32
- with open(path, 'a', encoding=encoding) as f:
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
- with open(path, 'rb') as f:
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
- Path(path).parent.mkdir(parents=True, exist_ok=True)
45
- with open(path, 'wb') as f:
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
- return os.path.exists(path)
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
- return os.path.isfile(path)
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
- return os.path.isdir(path)
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
- """Create directory."""
66
- Path(path).mkdir(parents=parents, exist_ok=True)
157
+ validated_path = FileSystemModule._validate_path(path, "remove")
158
+ os.remove(validated_path)
67
159
 
68
160
  @staticmethod
69
- def rmdir(path: str, recursive: bool = False) -> None:
70
- """Remove directory."""
71
- if recursive:
72
- shutil.rmtree(path)
73
- else:
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
- shutil.copytree(src, dst)
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]:
@@ -592,12 +592,13 @@ class LiteralPattern:
592
592
  return f"LiteralPattern({self.value})"
593
593
 
594
594
  class PropertyAccessExpression(Expression):
595
- def __init__(self, object, property):
595
+ def __init__(self, object, property, computed=False):
596
596
  self.object = object
597
597
  self.property = property
598
+ self.computed = computed # True for obj[expr], False for obj.prop
598
599
 
599
600
  def __repr__(self):
600
- return f"PropertyAccessExpression(object={self.object}, property={self.property})"
601
+ return f"PropertyAccessExpression(object={self.object}, property={self.property}, computed={self.computed})"
601
602
 
602
603
  class AssignmentExpression(Expression):
603
604
  def __init__(self, name, value):
@@ -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.2") -> Dict:
26
+ def init(self, name: str = None, version: str = "1.6.4") -> 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")