From 41f46f0f0b13fd3641219f599f3afb7fd8422197 Mon Sep 17 00:00:00 2001 From: msankows Date: Tue, 23 Sep 2025 11:34:50 +0200 Subject: [PATCH 1/4] add input fields for public certificate and private key --- package/src/asset.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/package/src/asset.py b/package/src/asset.py index baf9fae..6fb6512 100644 --- a/package/src/asset.py +++ b/package/src/asset.py @@ -28,22 +28,12 @@ class Asset(BaseAsset): description="Type of authentication token", default="ph-auth-token", ) - auth_token: str = AssetField( - required=False, description="Value of authentication token" - ) - username: str = AssetField( - required=False, description="Username (for HTTP basic auth)" - ) - password: str = AssetField( - required=False, description="Password (for HTTP basic auth)" - ) - oauth_token_url: str = AssetField( - required=False, description="URL to fetch oauth token from" - ) + auth_token: str = AssetField(required=False, description="Value of authentication token") + username: str = AssetField(required=False, description="Username (for HTTP basic auth)") + password: str = AssetField(required=False, description="Password (for HTTP basic auth)") + oauth_token_url: str = AssetField(required=False, description="URL to fetch oauth token from") client_id: str = AssetField(required=False, description="Client ID (for OAuth)") - client_secret: str = AssetField( - required=False, description="Client Secret (for OAuth)" - ) + client_secret: str = AssetField(required=False, description="Client Secret (for OAuth)") timeout: float = AssetField(required=False, description="Timeout for HTTP calls") test_http_method: str = AssetField( required=False, @@ -60,3 +50,5 @@ class Asset(BaseAsset): "PATCH", ], ) + public_cert: str = AssetField(required=False, sensitive=True, description="Public part of the client certificate") + private_key: str = AssetField(required=False, sensitive=True, description="Private key for the client certificate") From 258ac45800f0a2c71f7d5296947873be0ca0506b Mon Sep 17 00:00:00 2001 From: msankows Date: Tue, 23 Sep 2025 12:55:18 +0200 Subject: [PATCH 2/4] temporary file creator --- package/src/helpers.py | 55 ++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/package/src/helpers.py b/package/src/helpers.py index 64b26a8..1c957fe 100644 --- a/package/src/helpers.py +++ b/package/src/helpers.py @@ -13,14 +13,17 @@ # limitations under the License. import json from typing import Optional - +import tempfile +from contextlib import contextmanager, nullcontext import xmltodict from bs4 import BeautifulSoup from pydantic import ValidationError from soar_sdk.exceptions import ActionFailure - +import base64 from .common import logger from .schemas import ParsedResponseBody +from .asset import Asset +import os def process_xml_response(response) -> dict: @@ -36,9 +39,7 @@ def process_json_response(response) -> dict: except json.JSONDecodeError as e: raise ActionFailure(f"Server claimed JSON but failed to parse. Error: {e}") except ValidationError as e: - raise ActionFailure( - f"Response JSON did not match expected structure. Details: {e}" - ) + raise ActionFailure(f"Response JSON did not match expected structure. Details: {e}") def process_html_response(response) -> ParsedResponseBody: @@ -56,11 +57,7 @@ def process_html_response(response) -> ParsedResponseBody: def process_empty_response(content_type) -> dict: - message = ( - "Response includes a file" - if "octet-stream" in content_type - else "Empty response body" - ) + message = "Response includes a file" if "octet-stream" in content_type else "Empty response body" return {"message": message} @@ -90,16 +87,12 @@ def parse_headers(headers_str: Optional[str]) -> dict: parsed_headers = json.loads(headers_str) except json.JSONDecodeError as e: - error_message = ( - f"Failed to parse headers. Ensure it's a valid JSON object. Error: {e}" - ) + error_message = f"Failed to parse headers. Ensure it's a valid JSON object. Error: {e}" logger.error(error_message) raise ActionFailure(error_message) if not isinstance(parsed_headers, dict): - raise ActionFailure( - "Headers parameter must be a valid JSON object (dictionary)." - ) + raise ActionFailure("Headers parameter must be a valid JSON object (dictionary).") return parsed_headers @@ -126,3 +119,33 @@ def handle_various_response(response): else: raw_body = response.text return parsed_body, raw_body + + +@contextmanager +def _temp_cert_files(asset: Asset): + """ + Safely creates temporary files for a client certificate and key from Base64 strings + and yields their paths. Cleans up the files automatically. + """ + if not (asset.public_cert and asset.private_key): + yield None + return + + cert_path, key_path = None, None + + try: + cert_bytes = base64.b64decode(asset.public_cert) + key_bytes = base64.b64decode(asset.private_key) + + with tempfile.NamedTemporaryFile(mode="wb", delete=False) as cert_f, tempfile.NamedTemporaryFile(mode="wb", delete=False) as key_f: + cert_f.write(cert_bytes) + key_f.write(key_bytes) + cert_path = cert_f.name + key_path = key_f.name + + yield (cert_path, key_path) + finally: + if cert_path and os.path.exists(cert_path): + os.remove(cert_path) + if key_path and os.path.exists(key_path): + os.remove(key_path) From 66821acf2f7209fc3c8150d19c26cb4d7f4ef768 Mon Sep 17 00:00:00 2001 From: msankows Date: Tue, 23 Sep 2025 13:58:10 +0200 Subject: [PATCH 3/4] request_maker updated - enable certificate auth in requests --- package/src/helpers.py | 4 +- package/src/request_maker.py | 76 +++++++++++++++++------------------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/package/src/helpers.py b/package/src/helpers.py index 1c957fe..c906852 100644 --- a/package/src/helpers.py +++ b/package/src/helpers.py @@ -122,13 +122,13 @@ def handle_various_response(response): @contextmanager -def _temp_cert_files(asset: Asset): +def temp_cert_files(asset: Asset): """ Safely creates temporary files for a client certificate and key from Base64 strings and yields their paths. Cleans up the files automatically. """ if not (asset.public_cert and asset.private_key): - yield None + yield (None, None) return cert_path, key_path = None, None diff --git a/package/src/request_maker.py b/package/src/request_maker.py index 4dcfa28..6168538 100644 --- a/package/src/request_maker.py +++ b/package/src/request_maker.py @@ -23,6 +23,8 @@ from .asset import Asset from .auth import OAuth, get_auth_method from .common import logger +from contextlib import nullcontext +from .helpers import temp_cert_files def make_request( @@ -49,48 +51,40 @@ def make_request( logger.info(f"Making {method} request to: {full_url}") - body = ( - UnicodeDammit(body).unicode_markup.encode("utf-8") - if isinstance(body, str) - else body - ) - - retries = 1 - response = None - - while retries >= 0: - auth_method = get_auth_method(asset, soar) - auth_object, final_headers = auth_method.create_auth(parsed_headers) - - try: - response = requests.request( - method=method, - url=full_url, - auth=auth_object, - data=body, - verify=verify, - headers=final_headers, - timeout=asset.timeout, - ) - response.raise_for_status() - - break - - except requests.exceptions.RequestException as e: - if ( - isinstance(auth_method, OAuth) - and e.response - and e.response.status_code == 401 - and retries > 0 - ): - logger.warning( - "Request failed with 401, token might be expired. Forcing a refresh." + cert_manager = temp_cert_files(asset) if (asset.client_cert and asset.client_key) else nullcontext() + + body = UnicodeDammit(body).unicode_markup.encode("utf-8") if isinstance(body, str) else body + with cert_manager as cert_param: + retries = 1 + response = None + + while retries >= 0: + auth_method = get_auth_method(asset, soar) + auth_object, final_headers = auth_method.create_auth(parsed_headers) + + try: + response = requests.request( + method=method, + url=full_url, + auth=auth_object, + data=body, + verify=verify, + headers=final_headers, + cert=cert_param, + timeout=asset.timeout, ) - auth_method.get_token(force_new=True) - retries -= 1 - continue - else: - raise ActionFailure(f"Request failed for {full_url}. Details: {e}") + response.raise_for_status() + + break + + except requests.exceptions.RequestException as e: + if isinstance(auth_method, OAuth) and e.response and e.response.status_code == 401 and retries > 0: + logger.warning("Request failed with 401, token might be expired. Forcing a refresh.") + auth_method.get_token(force_new=True) + retries -= 1 + continue + else: + raise ActionFailure(f"Request failed for {full_url}. Details: {e}") parsed_body, raw_body = helpers.handle_various_response(response) logger.info(f"Successfully processed data. Status: {response.status_code}") From 6c58252adaa8d984e1b2a2c10e73969e6e14c384 Mon Sep 17 00:00:00 2001 From: msankows Date: Tue, 23 Sep 2025 14:32:22 +0200 Subject: [PATCH 4/4] refactoring --- package/src/helpers.py | 8 ++++---- package/src/request_maker.py | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/package/src/helpers.py b/package/src/helpers.py index c906852..1556d83 100644 --- a/package/src/helpers.py +++ b/package/src/helpers.py @@ -122,20 +122,20 @@ def handle_various_response(response): @contextmanager -def temp_cert_files(asset: Asset): +def temp_cert_files(cert_b64: str, key_b64: str): """ Safely creates temporary files for a client certificate and key from Base64 strings and yields their paths. Cleans up the files automatically. """ - if not (asset.public_cert and asset.private_key): + if not (cert_b64 and key_b64): yield (None, None) return cert_path, key_path = None, None + cert_bytes = base64.b64decode(asset.public_cert) + key_bytes = base64.b64decode(asset.private_key) try: - cert_bytes = base64.b64decode(asset.public_cert) - key_bytes = base64.b64decode(asset.private_key) with tempfile.NamedTemporaryFile(mode="wb", delete=False) as cert_f, tempfile.NamedTemporaryFile(mode="wb", delete=False) as key_f: cert_f.write(cert_bytes) diff --git a/package/src/request_maker.py b/package/src/request_maker.py index 6168538..4811fa6 100644 --- a/package/src/request_maker.py +++ b/package/src/request_maker.py @@ -23,7 +23,6 @@ from .asset import Asset from .auth import OAuth, get_auth_method from .common import logger -from contextlib import nullcontext from .helpers import temp_cert_files @@ -51,10 +50,9 @@ def make_request( logger.info(f"Making {method} request to: {full_url}") - cert_manager = temp_cert_files(asset) if (asset.client_cert and asset.client_key) else nullcontext() - body = UnicodeDammit(body).unicode_markup.encode("utf-8") if isinstance(body, str) else body - with cert_manager as cert_param: + + with temp_cert_files(asset.public_cert, asset.private_key) as cert_param: retries = 1 response = None