xenfra 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.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.
xenfra/utils/config.py ADDED
@@ -0,0 +1,278 @@
1
+ """
2
+ Configuration file generation utilities.
3
+ """
4
+ import os
5
+ import shutil
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+ from rich.prompt import Confirm, IntPrompt, Prompt
11
+ from xenfra_sdk import CodebaseAnalysisResponse
12
+
13
+
14
+ def read_xenfra_yaml(filename: str = "xenfra.yaml") -> dict:
15
+ """
16
+ Read and parse xenfra.yaml configuration file.
17
+
18
+ Args:
19
+ filename: Path to the config file (default: xenfra.yaml)
20
+
21
+ Returns:
22
+ Dictionary containing the configuration
23
+
24
+ Raises:
25
+ FileNotFoundError: If the config file doesn't exist
26
+ """
27
+ if not Path(filename).exists():
28
+ raise FileNotFoundError(f"Configuration file '{filename}' not found. Run 'xenfra init' first.")
29
+
30
+ with open(filename, 'r') as f:
31
+ return yaml.safe_load(f) or {}
32
+
33
+
34
+ def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xenfra.yaml") -> str:
35
+ """
36
+ Generate xenfra.yaml from AI codebase analysis.
37
+
38
+ Args:
39
+ analysis: CodebaseAnalysisResponse from Intelligence Service
40
+ filename: Output filename (default: xenfra.yaml)
41
+
42
+ Returns:
43
+ Path to the generated file
44
+ """
45
+ # Build configuration dictionary
46
+ config = {
47
+ 'name': os.path.basename(os.getcwd()),
48
+ 'framework': analysis.framework,
49
+ 'port': analysis.port,
50
+ }
51
+
52
+ # Add database configuration if detected
53
+ if analysis.database and analysis.database != 'none':
54
+ config['database'] = {
55
+ 'type': analysis.database,
56
+ 'env_var': 'DATABASE_URL'
57
+ }
58
+
59
+ # Add cache configuration if detected
60
+ if analysis.cache and analysis.cache != 'none':
61
+ config['cache'] = {
62
+ 'type': analysis.cache,
63
+ 'env_var': f"{analysis.cache.upper()}_URL"
64
+ }
65
+
66
+ # Add worker configuration if detected
67
+ if analysis.workers and len(analysis.workers) > 0:
68
+ config['workers'] = analysis.workers
69
+
70
+ # Add environment variables
71
+ if analysis.env_vars and len(analysis.env_vars) > 0:
72
+ config['env_vars'] = analysis.env_vars
73
+
74
+ # Add instance size
75
+ config['instance_size'] = analysis.instance_size
76
+
77
+ # Add package manager info (for intelligent diagnosis)
78
+ if analysis.package_manager:
79
+ config['package_manager'] = analysis.package_manager
80
+ if analysis.dependency_file:
81
+ config['dependency_file'] = analysis.dependency_file
82
+
83
+ # Write to file
84
+ with open(filename, 'w') as f:
85
+ yaml.dump(config, f, sort_keys=False, default_flow_style=False)
86
+
87
+ return filename
88
+
89
+
90
+ def create_backup(file_path: str) -> str:
91
+ """
92
+ Create a timestamped backup of a file in .xenfra/backups/ directory.
93
+
94
+ Args:
95
+ file_path: Path to the file to backup
96
+
97
+ Returns:
98
+ Path to the backup file
99
+ """
100
+ # Create .xenfra/backups directory if it doesn't exist
101
+ backup_dir = Path(".xenfra") / "backups"
102
+ backup_dir.mkdir(parents=True, exist_ok=True)
103
+
104
+ # Generate timestamped backup filename
105
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
106
+ file_name = Path(file_path).name
107
+ backup_path = backup_dir / f"{file_name}.{timestamp}.backup"
108
+
109
+ # Copy file to backup location
110
+ shutil.copy2(file_path, backup_path)
111
+
112
+ return str(backup_path)
113
+
114
+
115
+ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool = True):
116
+ """
117
+ Apply a JSON patch to a configuration file with automatic backup.
118
+
119
+ Args:
120
+ patch: Patch object with file, operation, path, value
121
+ target_file: Optional override for the file to patch
122
+ create_backup_file: Whether to create a backup before patching (default: True)
123
+
124
+ Returns:
125
+ Path to the backup file if created, None otherwise
126
+ """
127
+ file_to_patch = target_file or patch.get('file')
128
+
129
+ if not file_to_patch:
130
+ raise ValueError("No target file specified in patch")
131
+
132
+ if not os.path.exists(file_to_patch):
133
+ raise FileNotFoundError(f"File '{file_to_patch}' not found")
134
+
135
+ # Create backup before modifying
136
+ backup_path = None
137
+ if create_backup_file:
138
+ backup_path = create_backup(file_to_patch)
139
+
140
+ # For YAML files
141
+ if file_to_patch.endswith(('.yaml', '.yml')):
142
+ with open(file_to_patch, 'r') as f:
143
+ config_data = yaml.safe_load(f) or {}
144
+
145
+ # Apply patch based on operation
146
+ operation = patch.get('operation')
147
+ path = patch.get('path', '').strip('/')
148
+ value = patch.get('value')
149
+
150
+ if operation == 'add':
151
+ # For simple paths, add to root
152
+ if path:
153
+ path_parts = path.split('/')
154
+ current = config_data
155
+ for part in path_parts[:-1]:
156
+ if part not in current:
157
+ current[part] = {}
158
+ current = current[part]
159
+ current[path_parts[-1]] = value
160
+ else:
161
+ # Add to root level
162
+ if isinstance(value, dict):
163
+ config_data.update(value)
164
+ else:
165
+ config_data = value
166
+
167
+ elif operation == 'replace':
168
+ if path:
169
+ path_parts = path.split('/')
170
+ current = config_data
171
+ for part in path_parts[:-1]:
172
+ current = current[part]
173
+ current[path_parts[-1]] = value
174
+ else:
175
+ config_data = value
176
+
177
+ # Write back
178
+ with open(file_to_patch, 'w') as f:
179
+ yaml.dump(config_data, f, sort_keys=False, default_flow_style=False)
180
+
181
+ # For text files (like requirements.txt)
182
+ elif file_to_patch.endswith('.txt'):
183
+ operation = patch.get('operation')
184
+ value = patch.get('value')
185
+
186
+ if operation == 'add':
187
+ # Append to file
188
+ with open(file_to_patch, 'a') as f:
189
+ f.write(f"\n{value}\n")
190
+ elif operation == 'replace':
191
+ # Replace entire file
192
+ with open(file_to_patch, 'w') as f:
193
+ f.write(str(value))
194
+ else:
195
+ raise NotImplementedError(f"Patching not supported for file type: {file_to_patch}")
196
+
197
+ return backup_path
198
+
199
+
200
+ def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
201
+ """
202
+ Prompt user interactively for configuration details and generate xenfra.yaml.
203
+
204
+ Args:
205
+ filename: Output filename (default: xenfra.yaml)
206
+
207
+ Returns:
208
+ Path to the generated file
209
+ """
210
+ config = {}
211
+
212
+ # Project name (default to directory name)
213
+ default_name = os.path.basename(os.getcwd())
214
+ config['name'] = Prompt.ask("Project name", default=default_name)
215
+
216
+ # Framework
217
+ framework = Prompt.ask(
218
+ "Framework",
219
+ choices=["fastapi", "flask", "django", "other"],
220
+ default="fastapi"
221
+ )
222
+ config['framework'] = framework
223
+
224
+ # Port
225
+ port = IntPrompt.ask("Application port", default=8000)
226
+ config['port'] = port
227
+
228
+ # Database
229
+ use_database = Confirm.ask("Does your app use a database?", default=False)
230
+ if use_database:
231
+ db_type = Prompt.ask(
232
+ "Database type",
233
+ choices=["postgresql", "mysql", "sqlite", "mongodb"],
234
+ default="postgresql"
235
+ )
236
+ config['database'] = {
237
+ 'type': db_type,
238
+ 'env_var': 'DATABASE_URL'
239
+ }
240
+
241
+ # Cache
242
+ use_cache = Confirm.ask("Does your app use caching?", default=False)
243
+ if use_cache:
244
+ cache_type = Prompt.ask(
245
+ "Cache type",
246
+ choices=["redis", "memcached"],
247
+ default="redis"
248
+ )
249
+ config['cache'] = {
250
+ 'type': cache_type,
251
+ 'env_var': f"{cache_type.upper()}_URL"
252
+ }
253
+
254
+ # Instance size
255
+ instance_size = Prompt.ask(
256
+ "Instance size",
257
+ choices=["basic", "standard", "premium"],
258
+ default="basic"
259
+ )
260
+ config['instance_size'] = instance_size
261
+
262
+ # Environment variables
263
+ add_env = Confirm.ask("Add environment variables?", default=False)
264
+ if add_env:
265
+ env_vars = []
266
+ while True:
267
+ env_var = Prompt.ask("Environment variable name (blank to finish)", default="")
268
+ if not env_var:
269
+ break
270
+ env_vars.append(env_var)
271
+ if env_vars:
272
+ config['env_vars'] = env_vars
273
+
274
+ # Write to file
275
+ with open(filename, 'w') as f:
276
+ yaml.dump(config, f, sort_keys=False, default_flow_style=False)
277
+
278
+ return filename
@@ -0,0 +1,350 @@
1
+ """
2
+ Security utilities for Xenfra CLI.
3
+ Implements comprehensive URL validation, domain whitelisting, HTTPS enforcement, and certificate pinning.
4
+ """
5
+ import os
6
+ import ssl
7
+ from urllib.parse import urlparse
8
+
9
+ import certifi
10
+ import click
11
+ import httpx
12
+ from rich.console import Console
13
+
14
+ console = Console()
15
+
16
+ # Production API URL
17
+ PRODUCTION_API_URL = "https://api.xenfra.com" # TODO: Update with real production URL
18
+
19
+ # Allowed domains (whitelist) - Solution 2
20
+ ALLOWED_DOMAINS = [
21
+ "api.xenfra.com", # Production
22
+ "api-staging.xenfra.com", # Staging
23
+ "localhost", # Local development
24
+ "127.0.0.1", # Local development (IP)
25
+ ]
26
+
27
+ # Certificate fingerprints for pinning - Solution 4
28
+ # These should be updated when certificates are rotated
29
+ PINNED_CERTIFICATES = {
30
+ "api.xenfra.com": {
31
+ # SHA256 fingerprint of the expected certificate
32
+ # Example: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
33
+ # To get: openssl s_client -connect api.xenfra.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
34
+ "fingerprints": [], # Add actual fingerprints when you have production cert
35
+ }
36
+ }
37
+
38
+
39
+ class SecurityConfig:
40
+ """Security configuration for CLI."""
41
+
42
+ def __init__(self):
43
+ """Initialize security configuration from environment."""
44
+ # Environment detection - Solution 3
45
+ self.environment = os.getenv("XENFRA_ENV", "development").lower()
46
+
47
+ # Security settings (can be overridden by environment variables)
48
+ self.enforce_https = os.getenv("XENFRA_ENFORCE_HTTPS", "false").lower() == "true"
49
+ self.enforce_whitelist = os.getenv("XENFRA_ENFORCE_WHITELIST", "false").lower() == "true"
50
+ self.enable_cert_pinning = os.getenv("XENFRA_ENABLE_CERT_PINNING", "false").lower() == "true"
51
+ self.warn_on_http = os.getenv("XENFRA_WARN_ON_HTTP", "true").lower() == "true"
52
+
53
+ # Auto-enable strict security in production
54
+ if self.environment == "production":
55
+ self.enforce_https = True
56
+ self.enforce_whitelist = True
57
+ self.enable_cert_pinning = True
58
+
59
+ def is_production(self) -> bool:
60
+ """Check if running in production environment."""
61
+ return self.environment == "production"
62
+
63
+ def is_development(self) -> bool:
64
+ """Check if running in development environment."""
65
+ return self.environment in ["development", "dev", "local"]
66
+
67
+
68
+ # Global security configuration
69
+ security_config = SecurityConfig()
70
+
71
+
72
+ def validate_url_format(url: str) -> dict:
73
+ """
74
+ Solution 1: Basic URL validation.
75
+
76
+ Args:
77
+ url: The URL to validate
78
+
79
+ Returns:
80
+ Parsed URL components
81
+
82
+ Raises:
83
+ ValueError: If URL format is invalid
84
+ """
85
+ try:
86
+ parsed = urlparse(url)
87
+
88
+ # Check scheme
89
+ if parsed.scheme not in ["http", "https"]:
90
+ raise ValueError(
91
+ f"Invalid URL scheme '{parsed.scheme}'. "
92
+ f"Only 'http' and 'https' are allowed."
93
+ )
94
+
95
+ # Check hostname exists
96
+ if not parsed.hostname:
97
+ raise ValueError("URL must include a hostname")
98
+
99
+ # Check for malicious patterns
100
+ if ".." in url or "@" in url:
101
+ raise ValueError("URL contains suspicious characters")
102
+
103
+ # Prevent URL with credentials (http://user:pass@host)
104
+ if parsed.username or parsed.password:
105
+ raise ValueError("URLs with embedded credentials are not allowed")
106
+
107
+ return {
108
+ "scheme": parsed.scheme,
109
+ "hostname": parsed.hostname,
110
+ "port": parsed.port,
111
+ "url": url
112
+ }
113
+
114
+ except Exception as e:
115
+ raise ValueError(f"Invalid URL format: {e}")
116
+
117
+
118
+ def check_domain_whitelist(hostname: str) -> bool:
119
+ """
120
+ Solution 2: Domain whitelist validation.
121
+
122
+ Args:
123
+ hostname: The hostname to check
124
+
125
+ Returns:
126
+ True if domain is whitelisted
127
+
128
+ Raises:
129
+ ValueError: If domain is not whitelisted and enforcement is enabled
130
+ """
131
+ is_whitelisted = hostname in ALLOWED_DOMAINS
132
+
133
+ if not is_whitelisted:
134
+ if security_config.enforce_whitelist:
135
+ # Hard block in strict mode
136
+ raise ValueError(
137
+ f"Domain '{hostname}' is not in the whitelist.\n"
138
+ f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}\n\n"
139
+ f"If you're using a self-hosted Xenfra instance:\n"
140
+ f"1. Set XENFRA_ENFORCE_WHITELIST=false\n"
141
+ f"2. Or contact support to whitelist your domain"
142
+ )
143
+ else:
144
+ # Soft warning in permissive mode
145
+ console.print(
146
+ f"[yellow]⚠️ Warning: Domain '{hostname}' is not in the official whitelist.[/yellow]"
147
+ )
148
+ console.print(
149
+ f"[dim]Whitelisted domains: {', '.join(ALLOWED_DOMAINS)}[/dim]"
150
+ )
151
+ console.print(
152
+ f"[yellow]Are you sure you want to connect to this API?[/yellow]"
153
+ )
154
+
155
+ if not click.confirm("Continue?", default=False):
156
+ raise click.Abort()
157
+
158
+ return is_whitelisted
159
+
160
+
161
+ def enforce_https(scheme: str, hostname: str) -> None:
162
+ """
163
+ Solution 3: HTTPS enforcement.
164
+
165
+ Args:
166
+ scheme: URL scheme (http/https)
167
+ hostname: The hostname
168
+
169
+ Raises:
170
+ ValueError: If HTTPS is required but HTTP is used
171
+ """
172
+ # Development exception: localhost is OK with HTTP
173
+ is_localhost = hostname in ["localhost", "127.0.0.1"]
174
+
175
+ if scheme == "http" and not is_localhost:
176
+ if security_config.enforce_https:
177
+ # Hard block in production/strict mode
178
+ raise ValueError(
179
+ f"HTTPS is required (environment: {security_config.environment}).\n"
180
+ f"Current URL uses insecure HTTP.\n\n"
181
+ f"To fix:\n"
182
+ f"1. Update XENFRA_API_URL to use https://\n"
183
+ f"2. Or set XENFRA_ENFORCE_HTTPS=false (not recommended)"
184
+ )
185
+ elif security_config.warn_on_http:
186
+ # Soft warning
187
+ console.print("[bold yellow]⚠️ Security Warning: Using unencrypted HTTP![/bold yellow]")
188
+ console.print(f"[yellow]Connecting to: {scheme}://{hostname}[/yellow]")
189
+ console.print("[yellow]Your credentials and data will be sent in plain text.[/yellow]")
190
+ console.print("[yellow]This should ONLY be used for local development.[/yellow]\n")
191
+
192
+ if not click.confirm("Continue with insecure connection?", default=False):
193
+ raise click.Abort()
194
+
195
+
196
+ def create_secure_client(url: str, token: str = None) -> httpx.Client:
197
+ """
198
+ Solution 4: Create HTTP client with optional certificate pinning.
199
+
200
+ Args:
201
+ url: The base URL
202
+ token: Optional authentication token
203
+
204
+ Returns:
205
+ Configured httpx.Client with security settings
206
+ """
207
+ parsed = urlparse(url)
208
+ headers = {"Content-Type": "application/json"}
209
+
210
+ if token:
211
+ headers["Authorization"] = f"Bearer {token}"
212
+
213
+ # Certificate pinning for production domains (if enabled)
214
+ if security_config.enable_cert_pinning and parsed.hostname in PINNED_CERTIFICATES:
215
+ console.print(f"[dim]Enabling certificate pinning for {parsed.hostname}[/dim]")
216
+
217
+ # Create SSL context with certificate verification
218
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
219
+ ssl_context.check_hostname = True
220
+ ssl_context.verify_mode = ssl.CERT_REQUIRED
221
+
222
+ # Note: Full certificate pinning implementation would require custom verification
223
+ # For now, we use strict certificate validation
224
+ # TODO: Implement actual fingerprint verification if needed
225
+
226
+ return httpx.Client(
227
+ base_url=url,
228
+ headers=headers,
229
+ verify=ssl_context,
230
+ timeout=30.0,
231
+ )
232
+ else:
233
+ # Standard client with default certificate verification
234
+ return httpx.Client(
235
+ base_url=url,
236
+ headers=headers,
237
+ timeout=30.0,
238
+ )
239
+
240
+
241
+ def validate_and_get_api_url(url: str = None) -> str:
242
+ """
243
+ Comprehensive API URL validation (combines all 4 solutions).
244
+
245
+ Args:
246
+ url: Optional URL override (defaults to XENFRA_API_URL env var)
247
+
248
+ Returns:
249
+ Validated API URL
250
+
251
+ Raises:
252
+ ValueError: If URL fails validation
253
+ click.Abort: If user cancels security prompts
254
+ """
255
+ # Get URL from parameter or environment
256
+ if url is None:
257
+ url = os.getenv("XENFRA_API_URL")
258
+
259
+ # Use production URL in production environment
260
+ if url is None and security_config.is_production():
261
+ url = PRODUCTION_API_URL
262
+ # Use localhost in development
263
+ elif url is None:
264
+ url = "http://localhost:8000"
265
+
266
+ try:
267
+ # Solution 1: Validate URL format
268
+ parsed = validate_url_format(url)
269
+
270
+ # Solution 2: Check domain whitelist
271
+ check_domain_whitelist(parsed["hostname"])
272
+
273
+ # Solution 3: Enforce HTTPS
274
+ enforce_https(parsed["scheme"], parsed["hostname"])
275
+
276
+ # Display security info ONLY in debug mode
277
+ # Normal users shouldn't see this
278
+ if os.getenv("DEBUG") or os.getenv("XENFRA_DEBUG"):
279
+ console.print(f"[dim]🔒 Security: API URL validated: {url}[/dim]")
280
+ console.print(f"[dim] Environment: {security_config.environment}[/dim]")
281
+ console.print(f"[dim] HTTPS enforced: {security_config.enforce_https}[/dim]")
282
+ console.print(f"[dim] Whitelist enforced: {security_config.enforce_whitelist}[/dim]")
283
+ console.print(f"[dim] Cert pinning: {security_config.enable_cert_pinning}[/dim]")
284
+
285
+ return url
286
+
287
+ except ValueError as e:
288
+ console.print(f"[bold red]🔒 Security Validation Failed:[/bold red]")
289
+ console.print(f"[red]{e}[/red]")
290
+ raise click.Abort()
291
+
292
+
293
+ def display_security_info():
294
+ """Display current security configuration."""
295
+ console.print("\n[bold cyan]🔒 Security Configuration:[/bold cyan]")
296
+
297
+ table_data = [
298
+ ("Environment", security_config.environment),
299
+ ("HTTPS Enforcement", "✅ Enabled" if security_config.enforce_https else "⚠️ Disabled"),
300
+ ("Domain Whitelist", "✅ Enforced" if security_config.enforce_whitelist else "⚠️ Warning Only"),
301
+ ("HTTP Warning", "✅ Enabled" if security_config.warn_on_http else "❌ Disabled"),
302
+ ("Certificate Pinning", "✅ Enabled" if security_config.enable_cert_pinning else "❌ Disabled"),
303
+ ]
304
+
305
+ for key, value in table_data:
306
+ console.print(f" {key}: {value}")
307
+
308
+ console.print()
309
+
310
+
311
+ # Environment variable documentation
312
+ """
313
+ Security can be configured via environment variables:
314
+
315
+ XENFRA_ENV=production|staging|development
316
+ - Controls default security settings
317
+ - production: All security features enabled
318
+ - development: Permissive mode (localhost allowed)
319
+
320
+ XENFRA_ENFORCE_HTTPS=true|false
321
+ - Require HTTPS for all connections (except localhost)
322
+ - Default: false (dev), true (production)
323
+
324
+ XENFRA_ENFORCE_WHITELIST=true|false
325
+ - Block connections to non-whitelisted domains
326
+ - Default: false (dev), true (production)
327
+
328
+ XENFRA_ENABLE_CERT_PINNING=true|false
329
+ - Enable certificate pinning for production domains
330
+ - Default: false (dev), true (production)
331
+
332
+ XENFRA_WARN_ON_HTTP=true|false
333
+ - Show warning when using HTTP (non-localhost)
334
+ - Default: true
335
+
336
+ XENFRA_API_URL=https://api.example.com
337
+ - Override default API URL
338
+ - Subject to all security validations
339
+
340
+ Example usage:
341
+
342
+ # Development (permissive):
343
+ XENFRA_API_URL=http://localhost:8000 xenfra login
344
+
345
+ # Self-hosted instance (disable whitelist):
346
+ XENFRA_API_URL=https://xenfra.mycompany.com XENFRA_ENFORCE_WHITELIST=false xenfra login
347
+
348
+ # Production (strict):
349
+ XENFRA_ENV=production XENFRA_API_URL=https://api.xenfra.com xenfra login
350
+ """