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/__init__.py +0 -1
- xenfra/commands/__init__.py +3 -0
- xenfra/commands/auth.py +186 -0
- xenfra/commands/deployments.py +443 -0
- xenfra/commands/intelligence.py +312 -0
- xenfra/commands/projects.py +163 -0
- xenfra/commands/security_cmd.py +235 -0
- xenfra/main.py +70 -0
- xenfra/utils/__init__.py +3 -0
- xenfra/utils/auth.py +148 -0
- xenfra/utils/codebase.py +86 -0
- xenfra/utils/config.py +278 -0
- xenfra/utils/security.py +350 -0
- xenfra-0.2.4.dist-info/METADATA +115 -0
- xenfra-0.2.4.dist-info/RECORD +17 -0
- xenfra-0.2.4.dist-info/entry_points.txt +3 -0
- xenfra/api/auth.py +0 -51
- xenfra/api/billing.py +0 -80
- xenfra/api/connections.py +0 -163
- xenfra/api/main.py +0 -175
- xenfra/api/webhooks.py +0 -146
- xenfra/cli/main.py +0 -211
- xenfra/config.py +0 -24
- xenfra/db/models.py +0 -51
- xenfra/db/session.py +0 -17
- xenfra/dependencies.py +0 -35
- xenfra/dockerizer.py +0 -89
- xenfra/engine.py +0 -293
- xenfra/mcp_client.py +0 -149
- xenfra/models.py +0 -54
- xenfra/recipes.py +0 -23
- xenfra/security.py +0 -58
- xenfra/templates/Dockerfile.j2 +0 -25
- xenfra/templates/cloud-init.sh.j2 +0 -68
- xenfra/templates/docker-compose.yml.j2 +0 -33
- xenfra/utils.py +0 -69
- xenfra-0.2.2.dist-info/METADATA +0 -95
- xenfra-0.2.2.dist-info/RECORD +0 -25
- xenfra-0.2.2.dist-info/entry_points.txt +0 -3
- {xenfra-0.2.2.dist-info → xenfra-0.2.4.dist-info}/WHEEL +0 -0
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
|
xenfra/utils/security.py
ADDED
|
@@ -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
|
+
"""
|