xenfra 0.2.4__py3-none-any.whl → 0.2.6__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/commands/auth.py +180 -75
- xenfra/commands/deployments.py +42 -1
- xenfra/commands/intelligence.py +59 -28
- xenfra/commands/projects.py +54 -13
- xenfra/commands/security_cmd.py +16 -18
- xenfra/main.py +1 -0
- xenfra/utils/auth.py +99 -21
- xenfra/utils/codebase.py +55 -15
- xenfra/utils/config.py +151 -66
- xenfra/utils/security.py +29 -23
- xenfra/utils/validation.py +234 -0
- {xenfra-0.2.4.dist-info → xenfra-0.2.6.dist-info}/METADATA +26 -25
- xenfra-0.2.6.dist-info/RECORD +18 -0
- xenfra-0.2.4.dist-info/RECORD +0 -17
- {xenfra-0.2.4.dist-info → xenfra-0.2.6.dist-info}/WHEEL +0 -0
- {xenfra-0.2.4.dist-info → xenfra-0.2.6.dist-info}/entry_points.txt +0 -0
xenfra/utils/config.py
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Configuration file generation utilities.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import os
|
|
5
6
|
import shutil
|
|
6
7
|
from datetime import datetime
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
10
|
+
import click
|
|
9
11
|
import yaml
|
|
12
|
+
from rich.console import Console
|
|
10
13
|
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
11
14
|
from xenfra_sdk import CodebaseAnalysisResponse
|
|
12
15
|
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
13
18
|
|
|
14
19
|
def read_xenfra_yaml(filename: str = "xenfra.yaml") -> dict:
|
|
15
20
|
"""
|
|
16
21
|
Read and parse xenfra.yaml configuration file.
|
|
17
22
|
|
|
23
|
+
Args:
|
|
24
|
+
filename: Path to the config file (default: xenfra.yaml)
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Dictionary containing the configuration
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
FileNotFoundError: If the config file doesn't exist
|
|
31
|
+
yaml.YAMLError: If the YAML is malformed
|
|
32
|
+
"""
|
|
33
|
+
"""
|
|
34
|
+
Read and parse xenfra.yaml configuration file.
|
|
35
|
+
|
|
18
36
|
Args:
|
|
19
37
|
filename: Path to the config file (default: xenfra.yaml)
|
|
20
38
|
|
|
@@ -25,10 +43,17 @@ def read_xenfra_yaml(filename: str = "xenfra.yaml") -> dict:
|
|
|
25
43
|
FileNotFoundError: If the config file doesn't exist
|
|
26
44
|
"""
|
|
27
45
|
if not Path(filename).exists():
|
|
28
|
-
raise FileNotFoundError(
|
|
46
|
+
raise FileNotFoundError(
|
|
47
|
+
f"Configuration file '{filename}' not found. Run 'xenfra init' first."
|
|
48
|
+
)
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
try:
|
|
51
|
+
with open(filename, "r") as f:
|
|
52
|
+
return yaml.safe_load(f) or {}
|
|
53
|
+
except yaml.YAMLError as e:
|
|
54
|
+
raise ValueError(f"Invalid YAML in {filename}: {e}")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
raise IOError(f"Failed to read {filename}: {e}")
|
|
32
57
|
|
|
33
58
|
|
|
34
59
|
def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xenfra.yaml") -> str:
|
|
@@ -44,44 +69,38 @@ def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xe
|
|
|
44
69
|
"""
|
|
45
70
|
# Build configuration dictionary
|
|
46
71
|
config = {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
"name": os.path.basename(os.getcwd()),
|
|
73
|
+
"framework": analysis.framework,
|
|
74
|
+
"port": analysis.port,
|
|
50
75
|
}
|
|
51
76
|
|
|
52
77
|
# Add database configuration if detected
|
|
53
|
-
if analysis.database and analysis.database !=
|
|
54
|
-
config[
|
|
55
|
-
'type': analysis.database,
|
|
56
|
-
'env_var': 'DATABASE_URL'
|
|
57
|
-
}
|
|
78
|
+
if analysis.database and analysis.database != "none":
|
|
79
|
+
config["database"] = {"type": analysis.database, "env_var": "DATABASE_URL"}
|
|
58
80
|
|
|
59
81
|
# Add cache configuration if detected
|
|
60
|
-
if analysis.cache and analysis.cache !=
|
|
61
|
-
config[
|
|
62
|
-
'type': analysis.cache,
|
|
63
|
-
'env_var': f"{analysis.cache.upper()}_URL"
|
|
64
|
-
}
|
|
82
|
+
if analysis.cache and analysis.cache != "none":
|
|
83
|
+
config["cache"] = {"type": analysis.cache, "env_var": f"{analysis.cache.upper()}_URL"}
|
|
65
84
|
|
|
66
85
|
# Add worker configuration if detected
|
|
67
86
|
if analysis.workers and len(analysis.workers) > 0:
|
|
68
|
-
config[
|
|
87
|
+
config["workers"] = analysis.workers
|
|
69
88
|
|
|
70
89
|
# Add environment variables
|
|
71
90
|
if analysis.env_vars and len(analysis.env_vars) > 0:
|
|
72
|
-
config[
|
|
91
|
+
config["env_vars"] = analysis.env_vars
|
|
73
92
|
|
|
74
93
|
# Add instance size
|
|
75
|
-
config[
|
|
94
|
+
config["instance_size"] = analysis.instance_size
|
|
76
95
|
|
|
77
96
|
# Add package manager info (for intelligent diagnosis)
|
|
78
97
|
if analysis.package_manager:
|
|
79
|
-
config[
|
|
98
|
+
config["package_manager"] = analysis.package_manager
|
|
80
99
|
if analysis.dependency_file:
|
|
81
|
-
config[
|
|
100
|
+
config["dependency_file"] = analysis.dependency_file
|
|
82
101
|
|
|
83
102
|
# Write to file
|
|
84
|
-
with open(filename,
|
|
103
|
+
with open(filename, "w") as f:
|
|
85
104
|
yaml.dump(config, f, sort_keys=False, default_flow_style=False)
|
|
86
105
|
|
|
87
106
|
return filename
|
|
@@ -116,6 +135,36 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
|
|
|
116
135
|
"""
|
|
117
136
|
Apply a JSON patch to a configuration file with automatic backup.
|
|
118
137
|
|
|
138
|
+
Args:
|
|
139
|
+
patch: Patch object with file, operation, path, value
|
|
140
|
+
target_file: Optional override for the file to patch
|
|
141
|
+
create_backup_file: Whether to create a backup before patching (default: True)
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Path to the backup file if created, None otherwise
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If patch structure is invalid
|
|
148
|
+
FileNotFoundError: If target file doesn't exist
|
|
149
|
+
NotImplementedError: If file type is not supported
|
|
150
|
+
"""
|
|
151
|
+
# Validate patch structure
|
|
152
|
+
if not isinstance(patch, dict):
|
|
153
|
+
raise ValueError("Patch must be a dictionary")
|
|
154
|
+
|
|
155
|
+
required_fields = ["file", "operation"]
|
|
156
|
+
for field in required_fields:
|
|
157
|
+
if field not in patch:
|
|
158
|
+
raise ValueError(f"Patch missing required field: {field}")
|
|
159
|
+
|
|
160
|
+
operation = patch.get("operation")
|
|
161
|
+
if operation not in ["add", "replace", "remove"]:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"Invalid patch operation: {operation}. Must be 'add', 'replace', or 'remove'"
|
|
164
|
+
)
|
|
165
|
+
"""
|
|
166
|
+
Apply a JSON patch to a configuration file with automatic backup.
|
|
167
|
+
|
|
119
168
|
Args:
|
|
120
169
|
patch: Patch object with file, operation, path, value
|
|
121
170
|
target_file: Optional override for the file to patch
|
|
@@ -124,7 +173,7 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
|
|
|
124
173
|
Returns:
|
|
125
174
|
Path to the backup file if created, None otherwise
|
|
126
175
|
"""
|
|
127
|
-
file_to_patch = target_file or patch.get(
|
|
176
|
+
file_to_patch = target_file or patch.get("file")
|
|
128
177
|
|
|
129
178
|
if not file_to_patch:
|
|
130
179
|
raise ValueError("No target file specified in patch")
|
|
@@ -138,19 +187,19 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
|
|
|
138
187
|
backup_path = create_backup(file_to_patch)
|
|
139
188
|
|
|
140
189
|
# For YAML files
|
|
141
|
-
if file_to_patch.endswith((
|
|
142
|
-
with open(file_to_patch,
|
|
190
|
+
if file_to_patch.endswith((".yaml", ".yml")):
|
|
191
|
+
with open(file_to_patch, "r") as f:
|
|
143
192
|
config_data = yaml.safe_load(f) or {}
|
|
144
193
|
|
|
145
194
|
# Apply patch based on operation
|
|
146
|
-
operation = patch.get(
|
|
147
|
-
path = patch.get(
|
|
148
|
-
value = patch.get(
|
|
195
|
+
operation = patch.get("operation")
|
|
196
|
+
path = patch.get("path", "").strip("/")
|
|
197
|
+
value = patch.get("value")
|
|
149
198
|
|
|
150
|
-
if operation ==
|
|
199
|
+
if operation == "add":
|
|
151
200
|
# For simple paths, add to root
|
|
152
201
|
if path:
|
|
153
|
-
path_parts = path.split(
|
|
202
|
+
path_parts = path.split("/")
|
|
154
203
|
current = config_data
|
|
155
204
|
for part in path_parts[:-1]:
|
|
156
205
|
if part not in current:
|
|
@@ -164,9 +213,9 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
|
|
|
164
213
|
else:
|
|
165
214
|
config_data = value
|
|
166
215
|
|
|
167
|
-
elif operation ==
|
|
216
|
+
elif operation == "replace":
|
|
168
217
|
if path:
|
|
169
|
-
path_parts = path.split(
|
|
218
|
+
path_parts = path.split("/")
|
|
170
219
|
current = config_data
|
|
171
220
|
for part in path_parts[:-1]:
|
|
172
221
|
current = current[part]
|
|
@@ -175,23 +224,66 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
|
|
|
175
224
|
config_data = value
|
|
176
225
|
|
|
177
226
|
# Write back
|
|
178
|
-
with open(file_to_patch,
|
|
227
|
+
with open(file_to_patch, "w") as f:
|
|
179
228
|
yaml.dump(config_data, f, sort_keys=False, default_flow_style=False)
|
|
180
229
|
|
|
230
|
+
# For JSON files
|
|
231
|
+
elif file_to_patch.endswith(".json"):
|
|
232
|
+
import json
|
|
233
|
+
|
|
234
|
+
with open(file_to_patch, "r") as f:
|
|
235
|
+
config_data = json.load(f)
|
|
236
|
+
|
|
237
|
+
operation = patch.get("operation")
|
|
238
|
+
path = patch.get("path", "").strip("/")
|
|
239
|
+
value = patch.get("value")
|
|
240
|
+
|
|
241
|
+
if operation == "add":
|
|
242
|
+
if path:
|
|
243
|
+
path_parts = path.split("/")
|
|
244
|
+
current = config_data
|
|
245
|
+
for part in path_parts[:-1]:
|
|
246
|
+
if part not in current:
|
|
247
|
+
current[part] = {}
|
|
248
|
+
current = current[part]
|
|
249
|
+
current[path_parts[-1]] = value
|
|
250
|
+
else:
|
|
251
|
+
if isinstance(value, dict):
|
|
252
|
+
config_data.update(value)
|
|
253
|
+
else:
|
|
254
|
+
config_data = value
|
|
255
|
+
|
|
256
|
+
elif operation == "replace":
|
|
257
|
+
if path:
|
|
258
|
+
path_parts = path.split("/")
|
|
259
|
+
current = config_data
|
|
260
|
+
for part in path_parts[:-1]:
|
|
261
|
+
current = current[part]
|
|
262
|
+
current[path_parts[-1]] = value
|
|
263
|
+
else:
|
|
264
|
+
config_data = value
|
|
265
|
+
|
|
266
|
+
# Write back
|
|
267
|
+
with open(file_to_patch, "w") as f:
|
|
268
|
+
json.dump(config_data, f, indent=2)
|
|
269
|
+
|
|
181
270
|
# For text files (like requirements.txt)
|
|
182
|
-
elif file_to_patch.endswith(
|
|
183
|
-
operation = patch.get(
|
|
184
|
-
value = patch.get(
|
|
271
|
+
elif file_to_patch.endswith(".txt"):
|
|
272
|
+
operation = patch.get("operation")
|
|
273
|
+
value = patch.get("value")
|
|
185
274
|
|
|
186
|
-
if operation ==
|
|
275
|
+
if operation == "add":
|
|
187
276
|
# Append to file
|
|
188
|
-
with open(file_to_patch,
|
|
277
|
+
with open(file_to_patch, "a") as f:
|
|
189
278
|
f.write(f"\n{value}\n")
|
|
190
|
-
elif operation ==
|
|
279
|
+
elif operation == "replace":
|
|
191
280
|
# Replace entire file
|
|
192
|
-
with open(file_to_patch,
|
|
281
|
+
with open(file_to_patch, "w") as f:
|
|
193
282
|
f.write(str(value))
|
|
194
283
|
else:
|
|
284
|
+
# Design decision: Only support auto-patching for common dependency files
|
|
285
|
+
# Other file types should be manually edited to avoid data loss
|
|
286
|
+
# See docs/future-enhancements.md #4 for potential extensions
|
|
195
287
|
raise NotImplementedError(f"Patching not supported for file type: {file_to_patch}")
|
|
196
288
|
|
|
197
289
|
return backup_path
|
|
@@ -211,19 +303,24 @@ def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
|
|
|
211
303
|
|
|
212
304
|
# Project name (default to directory name)
|
|
213
305
|
default_name = os.path.basename(os.getcwd())
|
|
214
|
-
config[
|
|
306
|
+
config["name"] = Prompt.ask("Project name", default=default_name)
|
|
215
307
|
|
|
216
308
|
# Framework
|
|
217
309
|
framework = Prompt.ask(
|
|
218
|
-
"Framework",
|
|
219
|
-
choices=["fastapi", "flask", "django", "other"],
|
|
220
|
-
default="fastapi"
|
|
310
|
+
"Framework", choices=["fastapi", "flask", "django", "other"], default="fastapi"
|
|
221
311
|
)
|
|
222
|
-
config[
|
|
312
|
+
config["framework"] = framework
|
|
223
313
|
|
|
224
314
|
# Port
|
|
225
315
|
port = IntPrompt.ask("Application port", default=8000)
|
|
226
|
-
|
|
316
|
+
# Validate port
|
|
317
|
+
from .validation import validate_port
|
|
318
|
+
|
|
319
|
+
is_valid, error_msg = validate_port(port)
|
|
320
|
+
if not is_valid:
|
|
321
|
+
console.print(f"[bold red]Invalid port: {error_msg}[/bold red]")
|
|
322
|
+
raise click.Abort()
|
|
323
|
+
config["port"] = port
|
|
227
324
|
|
|
228
325
|
# Database
|
|
229
326
|
use_database = Confirm.ask("Does your app use a database?", default=False)
|
|
@@ -231,33 +328,21 @@ def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
|
|
|
231
328
|
db_type = Prompt.ask(
|
|
232
329
|
"Database type",
|
|
233
330
|
choices=["postgresql", "mysql", "sqlite", "mongodb"],
|
|
234
|
-
default="postgresql"
|
|
331
|
+
default="postgresql",
|
|
235
332
|
)
|
|
236
|
-
config[
|
|
237
|
-
'type': db_type,
|
|
238
|
-
'env_var': 'DATABASE_URL'
|
|
239
|
-
}
|
|
333
|
+
config["database"] = {"type": db_type, "env_var": "DATABASE_URL"}
|
|
240
334
|
|
|
241
335
|
# Cache
|
|
242
336
|
use_cache = Confirm.ask("Does your app use caching?", default=False)
|
|
243
337
|
if use_cache:
|
|
244
|
-
cache_type = Prompt.ask(
|
|
245
|
-
|
|
246
|
-
choices=["redis", "memcached"],
|
|
247
|
-
default="redis"
|
|
248
|
-
)
|
|
249
|
-
config['cache'] = {
|
|
250
|
-
'type': cache_type,
|
|
251
|
-
'env_var': f"{cache_type.upper()}_URL"
|
|
252
|
-
}
|
|
338
|
+
cache_type = Prompt.ask("Cache type", choices=["redis", "memcached"], default="redis")
|
|
339
|
+
config["cache"] = {"type": cache_type, "env_var": f"{cache_type.upper()}_URL"}
|
|
253
340
|
|
|
254
341
|
# Instance size
|
|
255
342
|
instance_size = Prompt.ask(
|
|
256
|
-
"Instance size",
|
|
257
|
-
choices=["basic", "standard", "premium"],
|
|
258
|
-
default="basic"
|
|
343
|
+
"Instance size", choices=["basic", "standard", "premium"], default="basic"
|
|
259
344
|
)
|
|
260
|
-
config[
|
|
345
|
+
config["instance_size"] = instance_size
|
|
261
346
|
|
|
262
347
|
# Environment variables
|
|
263
348
|
add_env = Confirm.ask("Add environment variables?", default=False)
|
|
@@ -269,10 +354,10 @@ def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
|
|
|
269
354
|
break
|
|
270
355
|
env_vars.append(env_var)
|
|
271
356
|
if env_vars:
|
|
272
|
-
config[
|
|
357
|
+
config["env_vars"] = env_vars
|
|
273
358
|
|
|
274
359
|
# Write to file
|
|
275
|
-
with open(filename,
|
|
360
|
+
with open(filename, "w") as f:
|
|
276
361
|
yaml.dump(config, f, sort_keys=False, default_flow_style=False)
|
|
277
362
|
|
|
278
363
|
return filename
|
xenfra/utils/security.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Security utilities for Xenfra CLI.
|
|
3
3
|
Implements comprehensive URL validation, domain whitelisting, HTTPS enforcement, and certificate pinning.
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
import os
|
|
6
7
|
import ssl
|
|
7
8
|
from urllib.parse import urlparse
|
|
@@ -14,23 +15,25 @@ from rich.console import Console
|
|
|
14
15
|
console = Console()
|
|
15
16
|
|
|
16
17
|
# Production API URL
|
|
17
|
-
PRODUCTION_API_URL = "https://api.xenfra.
|
|
18
|
+
PRODUCTION_API_URL = "https://api.xenfra.tech"
|
|
18
19
|
|
|
19
20
|
# Allowed domains (whitelist) - Solution 2
|
|
20
21
|
ALLOWED_DOMAINS = [
|
|
21
|
-
"api.xenfra.
|
|
22
|
-
"api-staging.xenfra.
|
|
23
|
-
"localhost",
|
|
24
|
-
"127.0.0.1",
|
|
22
|
+
"api.xenfra.tech", # Production
|
|
23
|
+
"api-staging.xenfra.tech", # Staging
|
|
24
|
+
"localhost", # Local development
|
|
25
|
+
"127.0.0.1", # Local development (IP)
|
|
25
26
|
]
|
|
26
27
|
|
|
27
28
|
# Certificate fingerprints for pinning - Solution 4
|
|
28
29
|
# These should be updated when certificates are rotated
|
|
29
30
|
PINNED_CERTIFICATES = {
|
|
30
|
-
"api.xenfra.
|
|
31
|
+
"api.xenfra.tech": {
|
|
31
32
|
# SHA256 fingerprint of the expected certificate
|
|
32
33
|
# Example: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
|
33
|
-
# To get
|
|
34
|
+
# To get fingerprint, run:
|
|
35
|
+
# openssl s_client -connect api.xenfra.tech:443 | openssl x509 -pubkey -noout \
|
|
36
|
+
# | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
|
|
34
37
|
"fingerprints": [], # Add actual fingerprints when you have production cert
|
|
35
38
|
}
|
|
36
39
|
}
|
|
@@ -47,7 +50,9 @@ class SecurityConfig:
|
|
|
47
50
|
# Security settings (can be overridden by environment variables)
|
|
48
51
|
self.enforce_https = os.getenv("XENFRA_ENFORCE_HTTPS", "false").lower() == "true"
|
|
49
52
|
self.enforce_whitelist = os.getenv("XENFRA_ENFORCE_WHITELIST", "false").lower() == "true"
|
|
50
|
-
self.enable_cert_pinning =
|
|
53
|
+
self.enable_cert_pinning = (
|
|
54
|
+
os.getenv("XENFRA_ENABLE_CERT_PINNING", "false").lower() == "true"
|
|
55
|
+
)
|
|
51
56
|
self.warn_on_http = os.getenv("XENFRA_WARN_ON_HTTP", "true").lower() == "true"
|
|
52
57
|
|
|
53
58
|
# Auto-enable strict security in production
|
|
@@ -88,8 +93,7 @@ def validate_url_format(url: str) -> dict:
|
|
|
88
93
|
# Check scheme
|
|
89
94
|
if parsed.scheme not in ["http", "https"]:
|
|
90
95
|
raise ValueError(
|
|
91
|
-
f"Invalid URL scheme '{parsed.scheme}'. "
|
|
92
|
-
f"Only 'http' and 'https' are allowed."
|
|
96
|
+
f"Invalid URL scheme '{parsed.scheme}'. " f"Only 'http' and 'https' are allowed."
|
|
93
97
|
)
|
|
94
98
|
|
|
95
99
|
# Check hostname exists
|
|
@@ -108,7 +112,7 @@ def validate_url_format(url: str) -> dict:
|
|
|
108
112
|
"scheme": parsed.scheme,
|
|
109
113
|
"hostname": parsed.hostname,
|
|
110
114
|
"port": parsed.port,
|
|
111
|
-
"url": url
|
|
115
|
+
"url": url,
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
except Exception as e:
|
|
@@ -145,12 +149,8 @@ def check_domain_whitelist(hostname: str) -> bool:
|
|
|
145
149
|
console.print(
|
|
146
150
|
f"[yellow]⚠️ Warning: Domain '{hostname}' is not in the official whitelist.[/yellow]"
|
|
147
151
|
)
|
|
148
|
-
console.print(
|
|
149
|
-
|
|
150
|
-
)
|
|
151
|
-
console.print(
|
|
152
|
-
f"[yellow]Are you sure you want to connect to this API?[/yellow]"
|
|
153
|
-
)
|
|
152
|
+
console.print(f"[dim]Whitelisted domains: {', '.join(ALLOWED_DOMAINS)}[/dim]")
|
|
153
|
+
console.print("[yellow]Are you sure you want to connect to this API?[/yellow]")
|
|
154
154
|
|
|
155
155
|
if not click.confirm("Continue?", default=False):
|
|
156
156
|
raise click.Abort()
|
|
@@ -220,8 +220,8 @@ def create_secure_client(url: str, token: str = None) -> httpx.Client:
|
|
|
220
220
|
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
|
221
221
|
|
|
222
222
|
# Note: Full certificate pinning implementation would require custom verification
|
|
223
|
-
# For now, we use strict certificate validation
|
|
224
|
-
#
|
|
223
|
+
# For now, we use strict certificate validation with system CA bundle
|
|
224
|
+
# Future enhancement: Certificate fingerprint pinning (see docs/future-enhancements.md #3)
|
|
225
225
|
|
|
226
226
|
return httpx.Client(
|
|
227
227
|
base_url=url,
|
|
@@ -285,7 +285,7 @@ def validate_and_get_api_url(url: str = None) -> str:
|
|
|
285
285
|
return url
|
|
286
286
|
|
|
287
287
|
except ValueError as e:
|
|
288
|
-
console.print(
|
|
288
|
+
console.print("[bold red]🔒 Security Validation Failed:[/bold red]")
|
|
289
289
|
console.print(f"[red]{e}[/red]")
|
|
290
290
|
raise click.Abort()
|
|
291
291
|
|
|
@@ -297,9 +297,15 @@ def display_security_info():
|
|
|
297
297
|
table_data = [
|
|
298
298
|
("Environment", security_config.environment),
|
|
299
299
|
("HTTPS Enforcement", "✅ Enabled" if security_config.enforce_https else "⚠️ Disabled"),
|
|
300
|
-
(
|
|
300
|
+
(
|
|
301
|
+
"Domain Whitelist",
|
|
302
|
+
"✅ Enforced" if security_config.enforce_whitelist else "⚠️ Warning Only",
|
|
303
|
+
),
|
|
301
304
|
("HTTP Warning", "✅ Enabled" if security_config.warn_on_http else "❌ Disabled"),
|
|
302
|
-
(
|
|
305
|
+
(
|
|
306
|
+
"Certificate Pinning",
|
|
307
|
+
"✅ Enabled" if security_config.enable_cert_pinning else "❌ Disabled",
|
|
308
|
+
),
|
|
303
309
|
]
|
|
304
310
|
|
|
305
311
|
for key, value in table_data:
|
|
@@ -346,5 +352,5 @@ XENFRA_API_URL=http://localhost:8000 xenfra login
|
|
|
346
352
|
XENFRA_API_URL=https://xenfra.mycompany.com XENFRA_ENFORCE_WHITELIST=false xenfra login
|
|
347
353
|
|
|
348
354
|
# Production (strict):
|
|
349
|
-
XENFRA_ENV=production XENFRA_API_URL=https://api.xenfra.
|
|
355
|
+
XENFRA_ENV=production XENFRA_API_URL=https://api.xenfra.tech xenfra login
|
|
350
356
|
"""
|