|
1 | 1 | # coding: utf-8 |
| 2 | +import atexit |
2 | 3 | import base64 |
3 | 4 | import datetime |
4 | 5 | import io |
5 | 6 | import json |
| 7 | +import os |
| 8 | +import shutil |
| 9 | +import signal |
| 10 | +import tempfile |
| 11 | +import threading |
6 | 12 | from typing import Dict, Tuple, Union |
7 | 13 |
|
8 | 14 | import magic |
|
78 | 84 | from pycti.utils.opencti_stix2 import OpenCTIStix2 |
79 | 85 | from pycti.utils.opencti_stix2_utils import OpenCTIStix2Utils |
80 | 86 |
|
| 87 | +# Global singleton variables for proxy certificate management |
| 88 | +_PROXY_CERT_BUNDLE = None |
| 89 | +_PROXY_CERT_DIR = None |
| 90 | +_PROXY_CERT_LOCK = threading.Lock() |
| 91 | +_PROXY_SIGNAL_HANDLERS_REGISTERED = False |
| 92 | + |
81 | 93 |
|
82 | 94 | def build_request_headers(token: str, custom_headers: str, app_logger): |
83 | 95 | headers_dict = { |
@@ -166,6 +178,9 @@ def __init__( |
166 | 178 | self.app_logger = self.logger_class("api") |
167 | 179 | self.admin_logger = self.logger_class("admin") |
168 | 180 |
|
| 181 | + # Setup proxy certificates if provided |
| 182 | + self._setup_proxy_certificates() |
| 183 | + |
169 | 184 | # Define API |
170 | 185 | self.api_token = token |
171 | 186 | self.api_url = url + "/graphql" |
@@ -249,6 +264,147 @@ def __init__( |
249 | 264 | "OpenCTI API is not reachable. Waiting for OpenCTI API to start or check your configuration..." |
250 | 265 | ) |
251 | 266 |
|
| 267 | + def _setup_proxy_certificates(self): |
| 268 | + """Setup HTTPS proxy certificates from environment variable. |
| 269 | +
|
| 270 | + Detects HTTPS_CA_CERTIFICATES environment variable and combines |
| 271 | + proxy certificates with system certificates for SSL verification. |
| 272 | + Supports both inline certificate content and file paths. |
| 273 | +
|
| 274 | + Uses a singleton pattern to ensure only one certificate bundle is created |
| 275 | + across all instances, avoiding resource leaks and conflicts. |
| 276 | + """ |
| 277 | + global _PROXY_CERT_BUNDLE, _PROXY_CERT_DIR, _PROXY_SIGNAL_HANDLERS_REGISTERED |
| 278 | + |
| 279 | + https_ca_certificates = os.getenv("HTTPS_CA_CERTIFICATES") |
| 280 | + if not https_ca_certificates: |
| 281 | + return |
| 282 | + |
| 283 | + # Thread-safe check and setup |
| 284 | + with _PROXY_CERT_LOCK: |
| 285 | + # If already configured, reuse existing bundle |
| 286 | + if _PROXY_CERT_BUNDLE is not None: |
| 287 | + self.ssl_verify = _PROXY_CERT_BUNDLE |
| 288 | + self.app_logger.debug( |
| 289 | + "Reusing existing proxy certificate bundle", |
| 290 | + {"cert_bundle": _PROXY_CERT_BUNDLE}, |
| 291 | + ) |
| 292 | + return |
| 293 | + |
| 294 | + # First initialization - create the certificate bundle |
| 295 | + try: |
| 296 | + # Create secure temporary directory |
| 297 | + cert_dir = tempfile.mkdtemp(prefix="opencti_proxy_certs_") |
| 298 | + |
| 299 | + # Determine if HTTPS_CA_CERTIFICATES contains inline content or file path |
| 300 | + cert_content = self._get_certificate_content(https_ca_certificates) |
| 301 | + |
| 302 | + # Write proxy certificate to temp file |
| 303 | + proxy_cert_file = os.path.join(cert_dir, "proxy-ca.crt") |
| 304 | + with open(proxy_cert_file, "w") as f: |
| 305 | + f.write(cert_content) |
| 306 | + |
| 307 | + # Find system certificates |
| 308 | + system_cert_paths = [ |
| 309 | + "/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu |
| 310 | + "/etc/pki/tls/certs/ca-bundle.crt", # RHEL/CentOS |
| 311 | + "/etc/ssl/cert.pem", # Alpine/BSD |
| 312 | + ] |
| 313 | + |
| 314 | + # Create combined certificate bundle |
| 315 | + combined_cert_file = os.path.join(cert_dir, "combined-ca-bundle.crt") |
| 316 | + with open(combined_cert_file, "w") as combined: |
| 317 | + # Add system certificates first |
| 318 | + for system_path in system_cert_paths: |
| 319 | + if os.path.exists(system_path): |
| 320 | + with open(system_path, "r") as sys_certs: |
| 321 | + combined.write(sys_certs.read()) |
| 322 | + combined.write("\n") |
| 323 | + break |
| 324 | + |
| 325 | + # Add proxy certificate |
| 326 | + combined.write(cert_content) |
| 327 | + |
| 328 | + # Update global singleton variables |
| 329 | + _PROXY_CERT_BUNDLE = combined_cert_file |
| 330 | + _PROXY_CERT_DIR = cert_dir |
| 331 | + self.ssl_verify = combined_cert_file |
| 332 | + |
| 333 | + # Set environment variables for urllib and other libraries |
| 334 | + os.environ["REQUESTS_CA_BUNDLE"] = combined_cert_file |
| 335 | + os.environ["SSL_CERT_FILE"] = combined_cert_file |
| 336 | + |
| 337 | + # Register cleanup handlers only once |
| 338 | + atexit.register(_cleanup_proxy_certificates) |
| 339 | + |
| 340 | + # Register signal handlers only once |
| 341 | + if not _PROXY_SIGNAL_HANDLERS_REGISTERED: |
| 342 | + signal.signal(signal.SIGTERM, _signal_handler_proxy_cleanup) |
| 343 | + signal.signal(signal.SIGINT, _signal_handler_proxy_cleanup) |
| 344 | + _PROXY_SIGNAL_HANDLERS_REGISTERED = True |
| 345 | + |
| 346 | + self.app_logger.info( |
| 347 | + "Proxy certificates configured", |
| 348 | + {"cert_bundle": combined_cert_file}, |
| 349 | + ) |
| 350 | + |
| 351 | + except Exception as e: |
| 352 | + self.app_logger.error( |
| 353 | + "Failed to setup proxy certificates", {"error": str(e)} |
| 354 | + ) |
| 355 | + raise |
| 356 | + |
| 357 | + def _get_certificate_content(self, https_ca_certificates): |
| 358 | + """Extract certificate content from environment variable. |
| 359 | +
|
| 360 | + Supports both inline certificate content (PEM format) and file paths. |
| 361 | +
|
| 362 | + :param https_ca_certificates: Content from HTTPS_CA_CERTIFICATES env var |
| 363 | + :type https_ca_certificates: str |
| 364 | + :return: Certificate content in PEM format |
| 365 | + :rtype: str |
| 366 | + :raises ValueError: If the certificate content is invalid or cannot be read |
| 367 | + """ |
| 368 | + # Strip whitespace once at the beginning |
| 369 | + stripped_https_ca_certificates = https_ca_certificates.strip() |
| 370 | + |
| 371 | + # Check if it's inline certificate content (starts with PEM header) |
| 372 | + if stripped_https_ca_certificates.startswith("-----BEGIN CERTIFICATE-----"): |
| 373 | + self.app_logger.debug( |
| 374 | + "HTTPS_CA_CERTIFICATES contains inline certificate content" |
| 375 | + ) |
| 376 | + return https_ca_certificates |
| 377 | + |
| 378 | + # Check if it's a file path |
| 379 | + if os.path.isfile(stripped_https_ca_certificates): |
| 380 | + cert_file_path = stripped_https_ca_certificates |
| 381 | + try: |
| 382 | + with open(cert_file_path, "r") as f: |
| 383 | + cert_content = f.read() |
| 384 | + # Validate it's actually a certificate |
| 385 | + if "-----BEGIN CERTIFICATE-----" in cert_content: |
| 386 | + self.app_logger.debug( |
| 387 | + "HTTPS_CA_CERTIFICATES contains valid certificate file path", |
| 388 | + {"file_path": cert_file_path}, |
| 389 | + ) |
| 390 | + return cert_content |
| 391 | + else: |
| 392 | + raise ValueError( |
| 393 | + f"File at HTTPS_CA_CERTIFICATES path does not contain valid certificate: {cert_file_path}" |
| 394 | + ) |
| 395 | + except ValueError: |
| 396 | + # Re-raise ValueError from certificate validation |
| 397 | + raise |
| 398 | + except Exception as e: |
| 399 | + raise ValueError( |
| 400 | + f"Failed to read certificate file at {cert_file_path}: {str(e)}" |
| 401 | + ) |
| 402 | + |
| 403 | + # Neither inline content nor valid file path |
| 404 | + raise ValueError( |
| 405 | + f"HTTPS_CA_CERTIFICATES is not a valid certificate or file path: {https_ca_certificates[:50]}..." |
| 406 | + ) |
| 407 | + |
252 | 408 | def set_applicant_id_header(self, applicant_id): |
253 | 409 | self.request_headers["opencti-applicant-id"] = applicant_id |
254 | 410 |
|
@@ -884,3 +1040,33 @@ def get_attribute_in_mitre_extension(key, object) -> any: |
884 | 1040 | "extension-definition--322b8f77-262a-4cb8-a915-1e441e00329b" |
885 | 1041 | ][key] |
886 | 1042 | return None |
| 1043 | + |
| 1044 | + |
| 1045 | +# Global cleanup functions for proxy certificates singleton |
| 1046 | +def _cleanup_proxy_certificates(): |
| 1047 | + """Clean up temporary certificate directory for proxy certificates. |
| 1048 | +
|
| 1049 | + This function is called on normal program exit via atexit. |
| 1050 | + """ |
| 1051 | + global _PROXY_CERT_DIR |
| 1052 | + if _PROXY_CERT_DIR and os.path.exists(_PROXY_CERT_DIR): |
| 1053 | + try: |
| 1054 | + shutil.rmtree(_PROXY_CERT_DIR) |
| 1055 | + except Exception: |
| 1056 | + # Silently fail cleanup - best effort |
| 1057 | + pass |
| 1058 | + finally: |
| 1059 | + _PROXY_CERT_DIR = None |
| 1060 | + |
| 1061 | + |
| 1062 | +def _signal_handler_proxy_cleanup(signum, frame): |
| 1063 | + """Handle termination signals (SIGTERM/SIGINT) for proxy certificate cleanup. |
| 1064 | +
|
| 1065 | + Performs cleanup and then raises SystemExit to allow |
| 1066 | + normal shutdown procedures to complete. |
| 1067 | +
|
| 1068 | + :param signum: Signal number |
| 1069 | + :param frame: Current stack frame |
| 1070 | + """ |
| 1071 | + _cleanup_proxy_certificates() |
| 1072 | + raise SystemExit(0) |
0 commit comments