Skip to content

Commit d95fb3d

Browse files
[client] Add proxy certificate support for HTTPS connections (#12177)
1 parent a2d728d commit d95fb3d

File tree

3 files changed

+477
-0
lines changed

3 files changed

+477
-0
lines changed

pycti/api/opencti_api_client.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
# coding: utf-8
2+
import atexit
23
import base64
34
import datetime
45
import io
56
import json
7+
import os
8+
import shutil
9+
import signal
10+
import tempfile
11+
import threading
612
from typing import Dict, Tuple, Union
713

814
import magic
@@ -78,6 +84,12 @@
7884
from pycti.utils.opencti_stix2 import OpenCTIStix2
7985
from pycti.utils.opencti_stix2_utils import OpenCTIStix2Utils
8086

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+
8193

8294
def build_request_headers(token: str, custom_headers: str, app_logger):
8395
headers_dict = {
@@ -166,6 +178,9 @@ def __init__(
166178
self.app_logger = self.logger_class("api")
167179
self.admin_logger = self.logger_class("admin")
168180

181+
# Setup proxy certificates if provided
182+
self._setup_proxy_certificates()
183+
169184
# Define API
170185
self.api_token = token
171186
self.api_url = url + "/graphql"
@@ -249,6 +264,147 @@ def __init__(
249264
"OpenCTI API is not reachable. Waiting for OpenCTI API to start or check your configuration..."
250265
)
251266

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+
252408
def set_applicant_id_header(self, applicant_id):
253409
self.request_headers["opencti-applicant-id"] = applicant_id
254410

@@ -884,3 +1040,33 @@ def get_attribute_in_mitre_extension(key, object) -> any:
8841040
"extension-definition--322b8f77-262a-4cb8-a915-1e441e00329b"
8851041
][key]
8861042
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)

tests/01-unit/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Unit tests for API client functionality

0 commit comments

Comments
 (0)