ic-code 1.0.0__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.
ic/config/secrets.py ADDED
@@ -0,0 +1,437 @@
1
+ """
2
+ Secrets management module for IC.
3
+
4
+ This module provides secure handling of sensitive configuration data.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Dict, Any, List, Optional, Union
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SecretsManager:
17
+ """
18
+ Manages sensitive configuration data with security validation.
19
+ """
20
+
21
+ def __init__(self, config_manager=None):
22
+ """
23
+ Initialize SecretsManager.
24
+
25
+ Args:
26
+ config_manager: Reference to ConfigManager instance
27
+ """
28
+ self.config_manager = config_manager
29
+ self.secrets_data: Dict[str, Any] = {}
30
+
31
+ # Define sensitive key patterns
32
+ self.sensitive_patterns = [
33
+ r'.*password.*',
34
+ r'.*passwd.*',
35
+ r'.*pwd.*',
36
+ r'.*token.*',
37
+ r'.*key.*',
38
+ r'.*secret.*',
39
+ r'.*credential.*',
40
+ r'.*webhook.*',
41
+ r'.*api_key.*',
42
+ r'.*access_key.*',
43
+ r'.*private_key.*',
44
+ r'.*client_secret.*',
45
+ r'.*tenant_id.*',
46
+ r'.*client_id.*',
47
+ ]
48
+
49
+ # Compile patterns for performance
50
+ self.compiled_patterns = [re.compile(pattern, re.IGNORECASE) for pattern in self.sensitive_patterns]
51
+
52
+ def load_secrets(self) -> Dict[str, Any]:
53
+ """
54
+ Load secrets from config/secrets.yaml with environment variable fallback.
55
+
56
+ Returns:
57
+ Dictionary containing sensitive configuration data
58
+ """
59
+ secrets = {}
60
+
61
+ # Try to load from secrets.yaml file first
62
+ secrets_path = Path("config/secrets.yaml")
63
+ if secrets_path.exists():
64
+ try:
65
+ secrets = self._load_secrets_file(secrets_path)
66
+ logger.debug("Loaded secrets from config/secrets.yaml")
67
+ except Exception as e:
68
+ logger.warning(f"Failed to load secrets from {secrets_path}: {e}")
69
+
70
+ # Fallback to environment variables
71
+ env_secrets = self._load_secrets_from_env()
72
+ if env_secrets:
73
+ secrets = self._merge_secrets(secrets, env_secrets)
74
+ logger.debug("Merged secrets from environment variables")
75
+
76
+ # Validate that secrets are properly separated (log to file only)
77
+ validation_warnings = self.validate_secrets_separation(secrets)
78
+ if validation_warnings:
79
+ for warning in validation_warnings:
80
+ logger.debug(f"Secrets validation: {warning}") # Changed to debug level
81
+
82
+ self.secrets_data = secrets
83
+ return secrets
84
+
85
+ def _load_secrets_file(self, secrets_path: Path) -> Dict[str, Any]:
86
+ """
87
+ Load secrets from YAML file.
88
+
89
+ Args:
90
+ secrets_path: Path to secrets.yaml file
91
+
92
+ Returns:
93
+ Dictionary containing secrets
94
+ """
95
+ import yaml
96
+
97
+ with open(secrets_path, 'r', encoding='utf-8') as f:
98
+ secrets = yaml.safe_load(f) or {}
99
+
100
+ # Validate file permissions
101
+ try:
102
+ file_mode = secrets_path.stat().st_mode & 0o777
103
+ if file_mode != 0o600:
104
+ logger.warning(f"Secrets file {secrets_path} has insecure permissions {oct(file_mode)}. "
105
+ f"Consider setting permissions to 600 (owner read/write only)")
106
+ except Exception as e:
107
+ logger.debug(f"Could not check file permissions: {e}")
108
+
109
+ return secrets
110
+
111
+ def _load_secrets_from_env(self) -> Dict[str, Any]:
112
+ """
113
+ Load sensitive configuration from environment variables.
114
+
115
+ Returns:
116
+ Dictionary containing environment-based secrets
117
+ """
118
+ env_secrets = {}
119
+
120
+ # AWS secrets
121
+ aws_accounts = os.getenv('AWS_ACCOUNTS')
122
+ if aws_accounts:
123
+ env_secrets.setdefault('aws', {})['accounts'] = [
124
+ acc.strip() for acc in aws_accounts.split(',') if acc.strip()
125
+ ]
126
+
127
+ # CloudFlare secrets
128
+ cf_secrets = {}
129
+ cf_email = os.getenv('CLOUDFLARE_EMAIL')
130
+ cf_token = os.getenv('CLOUDFLARE_API_TOKEN')
131
+ cf_accounts = os.getenv('CLOUDFLARE_ACCOUNTS')
132
+ cf_zones = os.getenv('CLOUDFLARE_ZONES')
133
+
134
+ if cf_email:
135
+ cf_secrets['email'] = cf_email
136
+ if cf_token:
137
+ cf_secrets['api_token'] = cf_token
138
+ if cf_accounts:
139
+ cf_secrets['accounts'] = [acc.strip() for acc in cf_accounts.split(',') if acc.strip()]
140
+ if cf_zones:
141
+ cf_secrets['zones'] = [zone.strip() for zone in cf_zones.split(',') if zone.strip()]
142
+
143
+ if cf_secrets:
144
+ env_secrets['cloudflare'] = cf_secrets
145
+
146
+ # GCP secrets
147
+ gcp_secrets = {}
148
+ gcp_key_path = os.getenv('GCP_SERVICE_ACCOUNT_KEY_PATH') or os.getenv('GOOGLE_APPLICATION_CREDENTIALS')
149
+ gcp_projects = os.getenv('GCP_PROJECTS')
150
+
151
+ if gcp_key_path:
152
+ gcp_secrets['service_account_key_path'] = gcp_key_path
153
+ if gcp_projects:
154
+ gcp_secrets['projects'] = [proj.strip() for proj in gcp_projects.split(',') if proj.strip()]
155
+
156
+ if gcp_secrets:
157
+ env_secrets['gcp'] = gcp_secrets
158
+
159
+ # Azure secrets
160
+ azure_secrets = {}
161
+ azure_tenant = os.getenv('AZURE_TENANT_ID')
162
+ azure_client_id = os.getenv('AZURE_CLIENT_ID')
163
+ azure_client_secret = os.getenv('AZURE_CLIENT_SECRET')
164
+ azure_subscriptions = os.getenv('AZURE_SUBSCRIPTIONS')
165
+
166
+ if azure_tenant:
167
+ azure_secrets['tenant_id'] = azure_tenant
168
+ if azure_client_id:
169
+ azure_secrets['client_id'] = azure_client_id
170
+ if azure_client_secret:
171
+ azure_secrets['client_secret'] = azure_client_secret
172
+ if azure_subscriptions:
173
+ azure_secrets['subscriptions'] = [sub.strip() for sub in azure_subscriptions.split(',') if sub.strip()]
174
+
175
+ if azure_secrets:
176
+ env_secrets['azure'] = azure_secrets
177
+
178
+ # Slack secrets
179
+ slack_webhook = os.getenv('SLACK_WEBHOOK_URL')
180
+ if slack_webhook:
181
+ env_secrets['slack'] = {'webhook_url': slack_webhook}
182
+
183
+ return env_secrets
184
+
185
+ def _merge_secrets(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
186
+ """
187
+ Merge two secrets dictionaries.
188
+
189
+ Args:
190
+ base: Base secrets dictionary
191
+ override: Override secrets dictionary
192
+
193
+ Returns:
194
+ Merged secrets dictionary
195
+ """
196
+ result = base.copy()
197
+
198
+ for key, value in override.items():
199
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
200
+ result[key] = self._merge_secrets(result[key], value)
201
+ else:
202
+ result[key] = value
203
+
204
+ return result
205
+
206
+ def validate_secrets_separation(self, config: Dict[str, Any]) -> List[str]:
207
+ """
208
+ Validate that sensitive information is properly separated from general config.
209
+
210
+ Args:
211
+ config: Configuration dictionary to validate
212
+
213
+ Returns:
214
+ List of validation warnings
215
+ """
216
+ warnings = []
217
+
218
+ # Check if any sensitive data appears in the general config
219
+ sensitive_keys_found = self._find_sensitive_keys(config)
220
+
221
+ for key_path in sensitive_keys_found:
222
+ warnings.append(f"Potentially sensitive key '{key_path}' found in general configuration. "
223
+ f"Consider moving to secrets.yaml")
224
+
225
+ return warnings
226
+
227
+ def _find_sensitive_keys(self, data: Any, path: str = "") -> List[str]:
228
+ """
229
+ Recursively find keys that match sensitive patterns.
230
+
231
+ Args:
232
+ data: Data to search
233
+ path: Current path in the data structure
234
+
235
+ Returns:
236
+ List of paths to sensitive keys
237
+ """
238
+ sensitive_keys = []
239
+
240
+ if isinstance(data, dict):
241
+ for key, value in data.items():
242
+ current_path = f"{path}.{key}" if path else key
243
+
244
+ # Check if key matches sensitive patterns
245
+ if self._is_sensitive_key(key):
246
+ sensitive_keys.append(current_path)
247
+
248
+ # Recursively check nested structures
249
+ sensitive_keys.extend(self._find_sensitive_keys(value, current_path))
250
+
251
+ elif isinstance(data, list):
252
+ for i, item in enumerate(data):
253
+ current_path = f"{path}[{i}]" if path else f"[{i}]"
254
+ sensitive_keys.extend(self._find_sensitive_keys(item, current_path))
255
+
256
+ return sensitive_keys
257
+
258
+ def _is_sensitive_key(self, key: str) -> bool:
259
+ """
260
+ Check if a key matches sensitive patterns.
261
+
262
+ Args:
263
+ key: Key to check
264
+
265
+ Returns:
266
+ True if key is potentially sensitive
267
+ """
268
+ return any(pattern.match(key) for pattern in self.compiled_patterns)
269
+
270
+ def mask_sensitive_values(self, data: Any) -> Any:
271
+ """
272
+ Recursively mask sensitive values in data structure.
273
+
274
+ Args:
275
+ data: Data structure to mask
276
+
277
+ Returns:
278
+ Data structure with sensitive values masked
279
+ """
280
+ if isinstance(data, dict):
281
+ masked_data = {}
282
+ for key, value in data.items():
283
+ if self._is_sensitive_key(key) and isinstance(value, str) and value:
284
+ # Mask the value but show first and last few characters for identification
285
+ if len(value) > 8:
286
+ masked_data[key] = f"{value[:3]}***{value[-3:]}"
287
+ else:
288
+ masked_data[key] = "***MASKED***"
289
+ else:
290
+ masked_data[key] = self.mask_sensitive_values(value)
291
+ return masked_data
292
+
293
+ elif isinstance(data, list):
294
+ return [self.mask_sensitive_values(item) for item in data]
295
+
296
+ else:
297
+ return data
298
+
299
+ def get_secret_value(self, key_path: str, default: Any = None) -> Any:
300
+ """
301
+ Get a secret value using dot notation.
302
+
303
+ Args:
304
+ key_path: Dot-separated path to secret value (e.g., 'aws.accounts')
305
+ default: Default value if key is not found
306
+
307
+ Returns:
308
+ Secret value or default
309
+ """
310
+ keys = key_path.split('.')
311
+ current = self.secrets_data
312
+
313
+ try:
314
+ for key in keys:
315
+ current = current[key]
316
+ return current
317
+ except (KeyError, TypeError):
318
+ return default
319
+
320
+ def has_secret(self, key_path: str) -> bool:
321
+ """
322
+ Check if a secret exists and has a non-empty value.
323
+
324
+ Args:
325
+ key_path: Dot-separated path to secret value
326
+
327
+ Returns:
328
+ True if secret exists and is not empty
329
+ """
330
+ value = self.get_secret_value(key_path)
331
+ return value is not None and value != "" and value != []
332
+
333
+ def create_secrets_template(self, output_path: Union[str, Path] = "config/secrets.yaml.template") -> bool:
334
+ """
335
+ Create a template secrets file with empty values.
336
+
337
+ Args:
338
+ output_path: Path where to create the template
339
+
340
+ Returns:
341
+ True if template was created successfully
342
+ """
343
+ template_content = '''# IC Secrets Configuration Template
344
+ # Copy this file to secrets.yaml and fill in your sensitive values
345
+ # File permissions should be set to 600 (readable only by owner)
346
+
347
+ version: "2.0"
348
+
349
+ # AWS sensitive configuration
350
+ aws:
351
+ accounts: [] # Add your AWS account IDs here, e.g., ["123456789012", "987654321098"]
352
+
353
+ # CloudFlare sensitive configuration
354
+ cloudflare:
355
+ email: "" # Your CloudFlare email
356
+ api_token: "" # Your CloudFlare API token
357
+ accounts: [] # Your CloudFlare account names
358
+ zones: [] # Your CloudFlare zone names
359
+
360
+ # GCP sensitive configuration
361
+ gcp:
362
+ service_account_key_path: "" # Path to your GCP service account key
363
+ projects: [] # Your GCP project IDs
364
+
365
+ # Azure sensitive configuration
366
+ azure:
367
+ tenant_id: "" # Your Azure tenant ID
368
+ client_id: "" # Your Azure client ID
369
+ client_secret: "" # Your Azure client secret
370
+ subscriptions: [] # Your Azure subscription IDs
371
+
372
+ # Slack integration
373
+ slack:
374
+ webhook_url: "" # Your Slack webhook URL
375
+
376
+ # Note: If this file doesn't exist or values are empty,
377
+ # the system will fall back to environment variables
378
+ '''
379
+
380
+ try:
381
+ output_path = Path(output_path)
382
+ output_path.parent.mkdir(parents=True, exist_ok=True)
383
+
384
+ with open(output_path, 'w', encoding='utf-8') as f:
385
+ f.write(template_content)
386
+
387
+ logger.info(f"Created secrets template at {output_path}")
388
+ return True
389
+
390
+ except Exception as e:
391
+ logger.error(f"Failed to create secrets template: {e}")
392
+ return False
393
+
394
+ def validate_secrets_file_security(self, secrets_path: Union[str, Path]) -> List[str]:
395
+ """
396
+ Validate security aspects of secrets file.
397
+
398
+ Args:
399
+ secrets_path: Path to secrets file
400
+
401
+ Returns:
402
+ List of security warnings
403
+ """
404
+ warnings = []
405
+ secrets_path = Path(secrets_path)
406
+
407
+ if not secrets_path.exists():
408
+ return warnings
409
+
410
+ try:
411
+ # Check file permissions
412
+ file_mode = secrets_path.stat().st_mode & 0o777
413
+ if file_mode & 0o077: # Check if group or others have any permissions
414
+ warnings.append(f"Secrets file {secrets_path} is readable by group/others. "
415
+ f"Current permissions: {oct(file_mode)}. "
416
+ f"Recommended: 600 (owner read/write only)")
417
+
418
+ # Check if file is in version control (basic check for .git directory)
419
+ git_dir = secrets_path.parent
420
+ while git_dir != git_dir.parent:
421
+ if (git_dir / ".git").exists():
422
+ gitignore_path = git_dir / ".gitignore"
423
+ if gitignore_path.exists():
424
+ with open(gitignore_path, 'r') as f:
425
+ gitignore_content = f.read()
426
+ if "secrets.yaml" not in gitignore_content:
427
+ warnings.append("secrets.yaml should be added to .gitignore to prevent "
428
+ "accidental commit of sensitive data")
429
+ else:
430
+ warnings.append("Consider creating .gitignore and adding secrets.yaml to it")
431
+ break
432
+ git_dir = git_dir.parent
433
+
434
+ except Exception as e:
435
+ logger.debug(f"Could not validate secrets file security: {e}")
436
+
437
+ return warnings