Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions Packs/OpenAI/Integrations/OpenAiChatGPTV3/OpenAiChatGPTV3.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class Config:
COMPLIANCE_PAGE_SIZE = 100
MAX_PAGES_PER_FETCH = 50 # Safety cap on pagination loops.

DEFAULT_FIRST_FETCH = "1 minute ago"
DEFAULT_FIRST_FETCH = "1 hour ago"

# Test-module probe: per-stream max-events ceiling when test_module exercises the collector
# via the same fetch_stream pipeline (mirrors Koi's TEST_MODULE_MAX_EVENTS=1).
Expand Down Expand Up @@ -490,6 +490,15 @@ def get_audit_logs(
# endregion

# region Event Collector - Compliance Logs (ChatGPT Platform - `Connect - Compliance` section)
def _compliance_headers(self) -> dict[str, str]:
"""Build the auth headers for the ChatGPT Compliance API.

The credential MUST carry the `Bearer` scheme - a bare key is not a well-formed
`Authorization` value and the API rejects it with 401 "Access token is missing"
without ever evaluating the key.
"""
return {"Authorization": f"Bearer {self.compliance_api_key}", "Accept": "application/json"}

def list_compliance_logs(
self,
workspace_id: str,
Expand Down Expand Up @@ -519,10 +528,10 @@ def list_compliance_logs(
params.append(("limit", effective_limit))

full_url = self.chatgpt_base_url + ApiPaths.compliance_logs(workspace_id)
headers = {"Authorization": self.compliance_api_key, "Accept": "application/json"}
headers = self._compliance_headers()
demisto.debug(
f"[API Compliance List] Listing logs | event_types_count={len(event_types)} | "
f"after_set={bool(after)} | limit={limit}"
f"[API Compliance List] Listing logs | url={full_url} | auth_scheme=Bearer | "
f"event_types_count={len(event_types)} | after_set={bool(after)} | limit={limit}"
)

# Fetch as text first so we can defensively handle single-JSON, concatenated-JSON, and empty
Expand Down Expand Up @@ -565,8 +574,8 @@ def get_compliance_log_content(self, workspace_id: str, log_id: str) -> list[dic
raise DemistoException("Compliance API Key is required to fetch OpenAI Compliance log content.")

full_url = self.chatgpt_base_url + ApiPaths.compliance_log_content(workspace_id, log_id)
headers = {"Authorization": self.compliance_api_key, "Accept": "application/json"}
demisto.debug("[API Compliance Content] Fetching content for one log entry.")
headers = self._compliance_headers()
demisto.debug(f"[API Compliance Content] Fetching content for one log entry | url={full_url} | auth_scheme=Bearer")

# The response body is a stream of concatenated JSON objects (or a JSONL file) - fetch raw text.
raw_body = self._http_request(method="GET", full_url=full_url, headers=headers, resp_type="text", **Config.RETRY_POLICY)
Expand Down
174 changes: 165 additions & 9 deletions Packs/OpenAI/Integrations/OpenAiChatGPTV3/OpenAiChatGPTV3_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,9 +509,9 @@ def test_parse_first_fetch_to_datetime_happy_path(mocker, first_fetch_input, exp
def test_parse_first_fetch_to_datetime_bad_input_falls_back_to_default(mocker, bad_input):
"""Unparseable input MUST fall back to `Config.DEFAULT_FIRST_FETCH`, never to a hardcoded window.

This locks the regression where a typo silently widened the lookback 1440× from
"1 minute ago" to "1 day ago". Whitespace, empty strings, and made-up unit names
all must reach the fallback path.
This locks the regression where a typo silently widened the lookback window beyond
the documented default. Whitespace, empty strings, and made-up unit names all must
reach the fallback path.
"""
# Suppress the demisto.error stdout under pytest; the error-log contract is asserted by
# test_parse_first_fetch_to_datetime_emits_error_log_on_bad_input.
Expand All @@ -521,17 +521,13 @@ def test_parse_first_fetch_to_datetime_bad_input_falls_back_to_default(mocker, b
assert isinstance(result, datetime)
assert result.tzinfo is not None, "Fallback must also be timezone-aware."

# The fallback MUST equal Config.DEFAULT_FIRST_FETCH (currently "1 minute ago"),
# NOT a hardcoded 1-day window.
# The fallback MUST equal Config.DEFAULT_FIRST_FETCH, never a hardcoded window.
expected_fallback = arg_to_datetime(Config.DEFAULT_FIRST_FETCH, is_utc=True)
assert expected_fallback is not None
if expected_fallback.tzinfo is None:
expected_fallback = expected_fallback.replace(tzinfo=UTC)
drift = abs((result - expected_fallback).total_seconds())
assert drift < 30, (
f"Fallback drifted {drift:.1f}s from Config.DEFAULT_FIRST_FETCH. "
f"If this exceeds the 1-minute window, the bug regressed."
)
assert drift < 30, f"Fallback drifted {drift:.1f}s from Config.DEFAULT_FIRST_FETCH - the bug regressed."


def test_parse_first_fetch_to_datetime_emits_error_log_on_bad_input(mocker):
Expand Down Expand Up @@ -2872,3 +2868,163 @@ def test_create_moderation_command_multi_text_more_results_than_texts(mocker):


# endregion


# region Compliance API authentication header
def test_compliance_headers_carry_bearer_scheme_and_accept():
"""
Given: A client configured with a Compliance API key.
When: The shared compliance header helper builds the headers.
Then: Authorization carries the `Bearer` scheme and Accept requests JSON.
"""
headers = _make_client()._compliance_headers()

assert headers == {"Authorization": "Bearer COMPLIANCE_KEY", "Accept": "application/json"}


def test_list_compliance_logs_sends_bearer_authorization_header(mocker):
"""
Given: A client configured with a valid Compliance API key.
When: Listing compliance logs.
Then: The Authorization header carries the `Bearer` scheme, which the ChatGPT Compliance
API requires. A bare key is rejected with 401 "Access token is missing".
"""
client = _make_client()
http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=json.dumps({"data": [], "last_end_time": None}))

client.list_compliance_logs(
workspace_id="FAKE_WORKSPACE_ID",
event_types=["AUDIT_LOG"],
after="2099-01-01T00:00:00Z",
limit=10,
)

assert http_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY"


def test_get_compliance_log_content_sends_bearer_authorization_header(mocker):
"""
Given: A client configured with a valid Compliance API key.
When: Fetching the content of a single compliance log entry (step 2 of the two-step flow).
Then: The Authorization header carries the `Bearer` scheme.
"""
client = _make_client()
http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value="{}")

client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID")

assert http_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY"


def test_audit_and_compliance_use_their_own_keys_with_bearer(mocker):
"""
Given: A client with distinct admin and compliance keys.
When: The audit endpoint and the compliance endpoint are each called.
Then: Each sends its own key, and both carry the `Bearer` scheme - the audit path must not
borrow the compliance credential, nor the reverse.
"""
client = _make_client()

audit_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value={"data": [], "has_more": False})
client.get_audit_logs(after=None, effective_at_gt=1)
assert audit_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer ADMIN_KEY"

compliance_mock = mocker.patch.object(
OpenAiClient, "_http_request", return_value=json.dumps({"data": [], "last_end_time": None})
)
client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z")
assert compliance_mock.call_args.kwargs["headers"]["Authorization"] == "Bearer COMPLIANCE_KEY"


def test_compliance_request_never_sends_a_bare_key(mocker):
"""
Bad path: a bare, scheme-less credential must not reappear in any form.

Given: A client with a Compliance API key.
When: The compliance listing is requested.
Then: The Authorization value is never the bare key, and never a doubled scheme.
"""
client = _make_client()
http_mock = mocker.patch.object(OpenAiClient, "_http_request", return_value=json.dumps({"data": []}))

client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z")

sent = http_mock.call_args.kwargs["headers"]["Authorization"]
assert sent != "COMPLIANCE_KEY", "regression: the raw key was sent with no auth scheme"
assert not sent.startswith("Bearer Bearer "), "the Bearer scheme was applied twice"
assert sent.startswith("Bearer ")


@pytest.mark.parametrize(
"status_code, message",
[
pytest.param(401, "Unauthorized - Access token is missing", id="bad-401-unauthorized"),
pytest.param(403, "Forbidden", id="bad-403-forbidden"),
pytest.param(429, "Too Many Requests", id="bad-429-rate-limited"),
pytest.param(500, "Internal Server Error", id="bad-500-server-error"),
],
)
def test_list_compliance_logs_propagates_api_errors(mocker, status_code, message):
"""
Bad path: the Compliance API rejects or fails the request.

Given: The Compliance API returns an error status.
When: Listing compliance logs.
Then: The error surfaces to the caller rather than being swallowed into an empty result,
so the instance test reports a real failure instead of silently collecting nothing.
"""
client = _make_client()
mocker.patch.object(
OpenAiClient, "_http_request", side_effect=DemistoException(f"Error in API call [{status_code}] - {message}")
)

with pytest.raises(DemistoException) as exc_info:
client.list_compliance_logs(workspace_id="FAKE_WORKSPACE_ID", event_types=["AUDIT_LOG"], after="2099-01-01T00:00:00Z")

assert str(status_code) in str(exc_info.value)


def test_get_compliance_log_content_propagates_unauthorized(mocker):
"""
Bad path: step 2 of the two-step flow is rejected.

Given: The compliance content endpoint returns 401.
When: Fetching the content of a log entry.
Then: The error surfaces rather than yielding an empty record list, which would look
like a log entry that legitimately had no content.
"""
client = _make_client()
mocker.patch.object(
OpenAiClient,
"_http_request",
side_effect=DemistoException("Error in API call [401] - Unauthorized - Access token is missing"),
)

with pytest.raises(DemistoException) as exc_info:
client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID")

assert "401" in str(exc_info.value)


@pytest.mark.parametrize(
"body",
[
pytest.param("", id="bad-empty-body"),
pytest.param(" ", id="bad-whitespace-only-body"),
],
)
def test_get_compliance_log_content_handles_empty_body(mocker, body):
"""
Bad path: the content endpoint returns nothing at all.

Given: The compliance content response body is empty or whitespace.
When: Fetching the content of a log entry.
Then: An empty list is returned rather than raising, so one empty entry cannot abort a fetch.
"""
client = _make_client()
mocker.patch.object(OpenAiClient, "_http_request", return_value=body)

assert client.get_compliance_log_content(workspace_id="FAKE_WORKSPACE_ID", log_id="FAKE_LOG_ID") == []


# endregion
6 changes: 6 additions & 0 deletions Packs/OpenAI/ReleaseNotes/2_1_5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

#### Integrations

##### OpenAI GPT

- Fixed an authentication issue with fetching Compliance logs.
2 changes: 1 addition & 1 deletion Packs/OpenAI/pack_metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "OpenAI",
"description": "The OpenAI API can be applied to virtually any task that involves understanding or generating natural language or code.",
"support": "xsoar",
"currentVersion": "2.1.4",
"currentVersion": "2.1.5",
"author": "Cortex XSOAR",
"url": "https://www.paloaltonetworks.com/cortex",
"email": "",
Expand Down
Loading