Jira Event Collector

Jira logs event collector integration for Cortex XSIAM.

Analytics & SIEM · Atlassian Jira

Details

IDJira Event Collector
ProviderAtlassian
CategoryAnalytics & SIEM
From Version6.5.0
Docker Imagedemisto/py3-tools:1.0.0.10120494
Supported ModulesAgentix Cortex Cloud Cloud Runtime Security Cloud Posture Security XSIAM EDR

README

This integration was integrated and tested with version 3 of Jira Event Collector rest API.

This is the default integration for this content pack when configured by the Data Onboarder in Cortex XSIAM.

Configure Jira Event Collector in Cortex

Parameter Description Required
Server URL For Jira Cloud with OAuth 2.0, use https://api.atlassian.com/ex/jira. For on-prem instances, use your Jira server URL. True
Authentication Method Select the authentication method: Basic or OAuth 2.0. Default is Basic. False
User name The user name for Basic Authentication (e.g., admin@example.com) False
API token The API token for Basic Authentication False
Cloud ID Required for Jira Cloud instances using OAuth 2.0. Find your Cloud ID at https://admin.atlassian.com. Leave empty for On-Prem instances. False
Callback URL The redirect URL for OAuth 2.0 (e.g., https://localhost/myapp) False
Client ID OAuth 2.0 Client ID False
Client Secret OAuth 2.0 Client Secret False
First fetch time (<number> <time unit>. For example, 12 hours, 1 day, 3 months). Default is 3 days. True
The maximum number of events per fetch Default is 1000. True
Trust any certificate (not secure)   False
Use system proxy settings   False

Authentication Methods

Basic Authentication

  1. Set Authentication Method to Basic
  2. Provide your User name (email address)
  3. Provide your API token (generate from Jira account settings)
  4. Set the Server URL to your Jira instance URL

OAuth 2.0 Authentication (Recommended)

OAuth 2.0 provides more secure authentication with limited scopes, avoiding the need for global administrator permissions.

For Jira Cloud

Required OAuth Scopes (must be configured in the Atlassian Developer Console for your OAuth app):

Scope Description
read:audit-log:jira Granular scope for reading Jira audit log records (required for event fetching)
manage:jira-configuration Classic admin scope required by the audit API
read:jira-work Required for Jira API v3 access
read:jira-user User information access
offline_access Enables refresh token for unattended access

Setup Steps:

  1. Create OAuth 2.0 App in Jira Cloud:
    • Go to https://developer.atlassian.com/console/myapps/
    • Create a new OAuth 2.0 integration app
    • Configure the callback URL (e.g., https://localhost/myapp)
    • Add the required scopes
    • Note your Client ID and Client Secret
  2. Configure Integration:
    • Set Authentication Method to OAuth 2.0
    • Set Server URL to https://api.atlassian.com/ex/jira
    • Enter your Cloud ID (find at https://admin.atlassian.com)
    • Enter the Callback URL (same as configured in OAuth app)
    • Enter Client ID and Client Secret
  3. Complete Authorization:
    • Run the command: !jira-oauth-start
    • Click the authorization URL in the response
    • Authorize the application
    • Copy the authorization code from the callback URL
    • Run: !jira-oauth-complete code=<your_code>
  4. Test Connection:
    • Run: !jira-oauth-test
    • Or use the Test button in the integration configuration

For Jira On-Prem/Data Center

Required OAuth Scopes:

Scope Description
ADMIN Admin-level scope required for audit log access on Data Center instances

Setup Steps:

  1. Create OAuth 2.0 App in Jira On-Prem:
    • Go to Jira Administration → Applications → Application Links
    • Create an incoming application link
    • Configure OAuth 2.0 settings
    • Set the callback URL (e.g., https://localhost/myapp)
    • Note your Client ID and Client Secret
  2. Configure Integration:
    • Set Authentication Method to OAuth 2.0
    • Set Server URL to your Jira server URL (e.g., https://jira.company.com)
    • Leave Cloud ID empty (not used for on-prem)
    • Enter the Callback URL (same as configured in OAuth app)
    • Enter Client ID and Client Secret
  3. Complete Authorization:
    • Run the command: !jira-oauth-start
    • Click the authorization URL in the response
    • Authorize the application
    • Copy the authorization code from the callback URL
    • Run: !jira-oauth-complete code=<your_code>
  4. Test Connection:
    • Run: !jira-oauth-test
    • Or use the Test button in the integration configuration

Note: On-Prem OAuth uses PKCE (Proof Key for Code Exchange) for enhanced security.

Commands

You can execute these commands from the War Room, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.

jira-oauth-start


Start the OAuth 2.0 authorization process. Returns an authorization URL.

Base Command

jira-oauth-start

Input

There are no input arguments for this command.

Context Output

There is no context output for this command.

Command example


#### Human Readable Output

>### Authorization Instructions
>
>1. Click on the following link to authorize:
>https://auth.atlassian.com/authorize?...
>
>2. After authorizing, you will be redirected to the callback URL
>3. Copy the authorization code from the 'code' parameter in the URL
>4. Run the command: `!jira-oauth-complete code=<your_code>`

---

### jira-oauth-complete

***
Complete the OAuth 2.0 authorization process using the authorization code.

#### Base Command

`jira-oauth-complete`

#### Input

| **Argument Name** | **Description** | **Required** |
| --- | --- | --- |
| code | The authorization code received from the OAuth callback | Required |

#### Context Output

There is no context output for this command.

#### Command example

```!jira-oauth-complete code=abc123xyz```

#### Human Readable Output

>### Successfully authenticated
>
>The access token and refresh token have been saved.
>You can now use the integration to fetch events.

---

### jira-oauth-test

***
Test the OAuth 2.0 authentication.

#### Base Command

`jira-oauth-test`

#### Input

There are no input arguments for this command.

#### Context Output

There is no context output for this command.

#### Command example

```!jira-oauth-test```

#### Human Readable Output

>✓ Authentication successful

---

### jira-get-events

***

#### Base Command

`jira-get-events`

#### Input

| **Argument Name** | **Description**                                                                                          | **Required** |
|-------------------|----------------------------------------------------------------------------------------------------------|--------------|
| max_fetch         | The maximum number of events per fetch. Default is 1000.                                 | Optional     |
| first_fetch       | First fetch time (&lt;number&gt; &lt;time unit&gt;, e.g., 12 hours, 1 day, 3 months). default is 3 days. | Optional     |

#### Command example

```!jira-get-events max_fetch=2```

#### Context Example

```json
{
    "Jira": {
        "Records": [
            {
                "associatedItems": [
                    {
                        "id": "ug:123456-123456-123456",
                        "name": "ug:123456-123456-123456",
                        "parentId": "111",
                        "parentName": "com.atlassian.crowd.directory.example",
                        "typeName": "USER"
                    }
                ],
                "category": "group management",
                "created": "2022-04-24T16:28:53.146+0000",
                "eventSource": "",
                "id": 1111,
                "objectItem": {
                    "name": "jira-servicemanagement-users",
                    "parentId": "111",
                    "parentName": "com.atlassian.crowd.directory.example",
                    "typeName": "GROUP"
                },
                "summary": "User added to group"
            },
            {
                "associatedItems": [
                    {
                        "id": "ug:123456-123456-123457",
                        "name": "ug:123456-123456-123457",
                        "parentId": "111",
                        "parentName": "com.atlassian.crowd.directory.example",
                        "typeName": "USER"
                    }
                ],
                "category": "group management",
                "created": "2022-04-24T16:28:53.098+0000",
                "eventSource": "",
                "id": 1110,
                "objectItem": {
                    "name": "jira-software-users",
                    "parentId": "111",
                    "parentName": "com.atlassian.crowd.directory.example",
                    "typeName": "GROUP"
                },
                "summary": "User added to group"
            }
        ]
    }
}

Human Readable Output

Jira records

Associated Items Category Created Id Object Item Summary
{‘id’: ‘ug:123456-123456-123456’, ‘name’: ‘ug:123456-123456-123456’, ‘typeName’: ‘USER’, ‘parentId’: ‘111’, ‘parentName’: ‘com.atlassian.crowd.directory.example’} group management 2022-04-24T16:28:53.146+0000 1111 name: jira-servicemanagement-users
typeName: GROUP
parentId: 111
parentName: com.atlassian.crowd.directory.example
User added to group
{‘id’: ‘ug:123456-123456-123457’, ‘name’: ‘ug:123456-123456-123457’, ‘typeName’: ‘USER’, ‘parentId’: ‘111’, ‘parentName’: ‘com.atlassian.crowd.directory.example’} group management 2022-04-24T16:28:53.098+0000 1110 name: jira-software-users
typeName: GROUP
parentId: 111
parentName: com.atlassian.crowd.directory.example
User added to group

Configuration parameters

  • url — Server URL (required)
  • auth_method — Authentication Method
  • credentials — User name
  • cloud_id — Cloud ID
  • callback_url — Callback URL
  • client_credentials — Client ID
  • first_fetch — First fetch time (<number> <time unit>, e.g., 12 hours, 1 day, 3 months). default is 3 days. (required)
  • max_fetch — The maximum number of events per fetch. Default is 1000. (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (4)

  • jira-get-events

    Returns a list of audit records

  • jira-oauth-complete

    Complete the OAuth 2.0 authorization process using the authorization code.

  • jira-oauth-start

    Start the OAuth 2.0 authorization process. Returns an authorization URL.

  • jira-oauth-test

    Test the OAuth 2.0 authentication

from datetime import datetime, timedelta
from enum import Enum

import dateparser
import demistomock as demisto  # noqa: F401
import requests
import urllib3
from CommonServerPython import *  # noqa: F401
from pydantic import AnyUrl, BaseConfig, BaseModel, Field, Json  # pylint: disable=no-name-in-module
from requests.auth import HTTPBasicAuth

from AtlassianApiModule import *  # type: ignore[import,assignment] # noqa: F401

urllib3.disable_warnings()

DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
VENDOR = "atlassian"
PRODUCT = "jira"


class Method(str, Enum):
    GET = "GET"
    POST = "POST"
    PUT = "PUT"
    HEAD = "HEAD"
    PATCH = "PATCH"
    DELETE = "DELETE"


class Args(BaseModel):
    from_: str = Field(
        datetime.strftime(
            dateparser.parse(demisto.params().get("first_fetch", "3 days"), settings={"TIMEZONE": "UTC"})
            or datetime.now() - timedelta(days=3),
            DATETIME_FORMAT,
        ),
        alias="from",
    )
    limit: int = 1000
    offset: int = 0


class ReqParams(BaseModel):
    from_: str = Field(
        datetime.strftime(
            dateparser.parse(demisto.params().get("first_fetch", "3 days"), settings={"TIMEZONE": "UTC"})
            or datetime.now() - timedelta(days=3),
            "%Y-%m-%dT%H:%M:%S.000Z",
        ),
        alias="from",
    )
    limit: int = 1000
    offset: int = 0


class Request(BaseModel):
    method: Method = Method.GET
    url: AnyUrl
    headers: Union[Json, dict] = {}
    params: ReqParams
    insecure: bool = Field(not demisto.params().get("insecure", False), alias="verify")
    proxy: bool = Field(demisto.params().get("proxy", False), alias="proxies")
    data: Optional[str] = None
    auth: Optional[HTTPBasicAuth] = Field(
        HTTPBasicAuth(
            demisto.params().get("credentials", {}).get("identifier"), demisto.params().get("credentials", {}).get("password")
        )
    )

    class Config(BaseConfig):
        arbitrary_types_allowed = True


class Client:
    def __init__(self, request: Request, session=requests.Session(), oauth_client=None):
        self.request = request
        self.session = session
        self.oauth_client = oauth_client
        self._set_proxy()
        self._set_cert_verification()

    def __del__(self):
        try:
            self.session.close()
        except AttributeError as err:
            demisto.debug(f"Ignore exceptions raised due to session not used by the client. {err}")

    def call(self) -> requests.Response:
        try:
            request_dict = self.request.dict(by_alias=True)

            # Handle OAuth authentication
            if self.oauth_client:
                access_token = self.oauth_client.get_access_token()
                if "headers" not in request_dict:
                    request_dict["headers"] = {}
                request_dict["headers"]["Authorization"] = f"Bearer {access_token}"
                # Remove basic auth if present
                request_dict.pop("auth", None)

            # Disable redirects to prevent following to login pages
            request_dict["allow_redirects"] = False

            demisto.debug(f"Sending request to {request_dict.get('url')} with method {request_dict.get('method')}")
            if "headers" in request_dict:
                headers_to_log = request_dict["headers"].copy()
                if "Authorization" in headers_to_log:
                    headers_to_log["Authorization"] = "Bearer *****"
                demisto.debug(f"Request headers: {headers_to_log}")

            response = self.session.request(**request_dict)

            demisto.debug(f"Response status code: {response.status_code}")
            if response.status_code in (301, 302, 303, 307, 308):
                raise DemistoException(
                    f"The request was redirected to '{response.headers.get('Location')}'.\n"
                    "This usually indicates an authentication issue (e.g., invalid credentials, expired token) "
                    "or an incorrect URL configuration.\n"
                    "Please verify your authentication credentials and URL."
                )

            try:
                response.raise_for_status()
            except Exception as e:
                demisto.debug(f"Request failed. Response content: {response.text}")
                raise e

            if "json" not in response.headers.get("Content-Type", "").lower():
                raise DemistoException(
                    f"Response content type is '{response.headers.get('Content-Type')}', but JSON was expected.\n"
                    "This usually occurs when authentication fails and the request is redirected to a login page.\n"
                    "Please verify your authentication credentials (API Key, Bearer Token, etc.) and URL."
                )

            return response
        except Exception as exc:
            msg = f"Something went wrong with the http call {exc}"
            LOG(msg)
            raise DemistoException(msg) from exc

    def prepare_next_run(self, offset: int):
        self.request.params.offset += offset

    def _set_cert_verification(self):
        if not self.request.insecure:
            skip_cert_verification()

    def _set_proxy(self):
        if self.request.proxy:
            ensure_proxy_has_http_prefix()
        else:
            skip_proxy()


class GetEvents:
    def __init__(self, client: Client) -> None:
        self.client = client

    def call(self) -> list:
        resp = self.client.call()
        json_response = resp.json()
        demisto.debug(f"API response keys: {json_response.keys() if isinstance(json_response, dict) else 'not a dict'}")
        demisto.debug(f"API response (first 1000 chars): {str(json_response)[:1000]}")

        # Jira Data Center /rest/auditing/1.0/events uses 'entities' key
        # Jira Cloud /rest/api/3/auditing/record uses 'records' key
        events = json_response.get("entities") or json_response.get("records", [])
        demisto.debug(f"Found {len(events)} events")
        return events

    def _iter_events(self):
        events = self.call()

        while events:
            yield events

            self.client.prepare_next_run(self.client.request.params.limit)
            events = self.call()

    def run(self, max_fetch: int = 1000) -> List[dict]:
        stored = []
        last_run = demisto.getLastRun()

        for logs in self._iter_events():
            stored.extend(logs)

            if len(stored) > max_fetch:
                last_run["offset"] = last_run.get("offset", 0) + max_fetch
                demisto.setLastRun(last_run)
                return stored[:max_fetch]

        last_run["offset"] = 0
        demisto.setLastRun(last_run)
        return stored

    @staticmethod
    def set_next_run(log: dict) -> dict:
        """
        Handles and saves the values required for next fetch.

        There are 3 values:
            * from: From which time to fetch
            * next_run: Time of creation of the last event fetched
            * offset: The size of the offset (how many events to skip)

        Since the rest API returns the events in desc order (the last event returns first), We need to save the last
        event time creation in some variable (next_run) for the next fetches, in addition we need to save in another
        variable (offset) the number of how many events we already fetched to skip them in the next fetch to avoid
        duplicates, in addition we need to save the time (from) from when to fetch if there is still some incident
        to fetch with offset
        """
        last_run = demisto.getLastRun()

        if not last_run.get("next_time"):
            # Jira Data Center uses 'timestamp', Cloud uses 'created'
            last_datetime = log.get("timestamp") or log.get("created", "")
            # Remove timezone suffix if present (e.g., +0000 or Z)
            last_datetime = last_datetime.removesuffix("+0000").removesuffix("Z")

            if not last_datetime:
                demisto.debug("No timestamp found in event, skipping next_run update")
                return last_run

            LOG_PREFIX = "[Jira Event Collector]"

            try:
                # Parse with milliseconds (Data Center format: 2026-01-19T11:36:01.892)
                demisto.debug(f"{LOG_PREFIX} Attempting to parse timestamp with milliseconds: {last_datetime}")
                last_datetime_obj = datetime.strptime(last_datetime, "%Y-%m-%dT%H:%M:%S.%f")
            except ValueError:
                try:
                    # Fallback for formats without milliseconds
                    demisto.debug(f"{LOG_PREFIX} Attempting to parse timestamp without milliseconds: {last_datetime}")
                    last_datetime_obj = datetime.strptime(last_datetime, "%Y-%m-%dT%H:%M:%S")
                except ValueError:
                    try:
                        # Try original format with microseconds
                        demisto.debug(f"{LOG_PREFIX} Attempting to parse timestamp with microseconds: {last_datetime}")
                        last_datetime_obj = datetime.strptime(last_datetime, DATETIME_FORMAT)
                    except ValueError as e:
                        demisto.debug(f"{LOG_PREFIX} Failed to parse timestamp '{last_datetime}': {e}")
                        return last_run

            # Add 1 second to ensure we don't fetch duplicates (since we truncate milliseconds)
            last_datetime_with_delta = last_datetime_obj + timedelta(seconds=1)

            # Use format without milliseconds for compatibility with Data Center
            next_time = datetime.strftime(last_datetime_with_delta, "%Y-%m-%dT%H:%M:%S")

            if last_run.get("offset"):
                last_run["next_time"] = next_time
            else:
                last_run["from"] = next_time

        else:
            if not last_run.get("offset"):
                last_run["from"] = last_run.pop("next_time")

        return last_run


def oauth_start_command(oauth_client) -> CommandResults:
    """Start OAuth authentication flow."""
    url = oauth_client.oauth_start()
    return CommandResults(
        readable_output=(
            f"### Authorization Instructions\n"
            f"1. Click on the following link to authorize:\n{url}\n\n"
            f"2. After authorizing, you will be redirected to the callback URL\n"
            f"3. Copy the authorization code from the 'code' parameter in the URL\n"
            f"4. Run the command: `!jira-oauth-complete code=<your_code>`"
        )
    )


def oauth_complete_command(oauth_client, code: str) -> CommandResults:
    """Complete OAuth authentication flow."""
    oauth_client.oauth_complete(code=code)
    return CommandResults(
        readable_output=(
            "### Successfully authenticated!\n"
            "The access token and refresh token have been saved.\n"
            "You can now use the integration to fetch events."
        )
    )


def oauth_test_command(oauth_client) -> CommandResults:
    """Test OAuth authentication."""
    try:
        oauth_client.test_connection()
        return CommandResults(readable_output="✓ Authentication successful")
    except Exception as e:
        raise DemistoException(f"Authentication failed: {str(e)}")


def main():
    # Args is always stronger. Get last run even stronger
    demisto_params = demisto.params() | demisto.args() | demisto.getLastRun()

    # Get authentication parameters
    auth_method = demisto_params.get("auth_method", "Basic")
    is_oauth = auth_method == "OAuth 2.0"

    # OAuth client initialization
    oauth_client = None
    if is_oauth:
        client_creds = demisto_params.get("client_credentials", {})
        client_id = client_creds.get("identifier", "")
        client_secret = client_creds.get("password", "")
        cloud_id = demisto_params.get("cloud_id", "")
        callback_url = demisto_params.get("callback_url", "")
        server_url = str(demisto_params.get("url", "")).removesuffix("/")

        if not client_id or not client_secret:
            raise DemistoException("Client ID and Client Secret are required for OAuth 2.0 authentication")
        if not callback_url:
            raise DemistoException("Callback URL is required for OAuth 2.0 authentication")

        # Create OAuth client using ApiModule (supports both Cloud and On-Prem)
        oauth_client = create_atlassian_oauth_client(
            client_id=client_id,
            client_secret=client_secret,
            callback_url=callback_url,
            cloud_id=cloud_id,
            server_url=server_url,
            verify=not demisto_params.get("insecure", False),
            proxy=demisto_params.get("proxy", False),
        )

    # Validate Basic auth credentials when using Basic authentication
    if not is_oauth:
        credentials = demisto_params.get("credentials", {})
        username = credentials.get("identifier", "")
        password = credentials.get("password", "")
        if not username or not password:
            return_error(
                "Username and API token are required for Basic authentication. "
                "Please provide valid credentials or switch to OAuth 2.0."
            )
            return

    # Build the API URL
    base_url = str(demisto_params.get("url", "")).removesuffix("/")
    if is_oauth and oauth_client and hasattr(oauth_client, "cloud_id") and oauth_client.cloud_id:
        # For OAuth with Cloud ID, use the cloud-specific URL
        # We force the base URL to be the Atlassian API gateway for Cloud OAuth
        # Must use API v3 — the v2 audit endpoint does not support OAuth 2.0 (3LO) granular scopes
        base_url = "https://api.atlassian.com/ex/jira"
        demisto_params["url"] = f"{base_url}/{oauth_client.cloud_id}/rest/api/3/auditing/record"
    else:
        # Distinguish Jira Cloud vs Server/Data Center by the base URL.
        # Jira Cloud (e.g., *.atlassian.net) should use the v3 auditing endpoint,
        # while Jira Server/Data Center continues to use the legacy auditing endpoint.
        lower_base_url = base_url.lower()
        if ".atlassian.net" in lower_base_url:
            # Jira Cloud with Basic auth (or non-cloud OAuth without cloud ID)
            demisto_params["url"] = f"{base_url}/rest/api/3/auditing/record"
        else:
            # Jira Server/Data Center
            demisto_params["url"] = f"{base_url}/rest/auditing/1.0/events"

    demisto_params["params"] = ReqParams.model_validate(demisto_params)  # type: ignore[attr-defined]

    request = Request.model_validate(demisto_params)  # type: ignore[attr-defined]
    client = Client(request, oauth_client=oauth_client)
    get_events = GetEvents(client)
    command = demisto.command()

    try:
        if command == "test-module":
            if oauth_client:
                # For OAuth, test the authentication
                oauth_client.test_connection()
            else:
                # For basic auth, try to fetch events
                get_events.run(max_fetch=1)
            demisto.results("ok")

        elif command == "jira-oauth-start":
            if not oauth_client:
                raise DemistoException("OAuth commands are only available when using OAuth 2.0 authentication")
            return_results(oauth_start_command(oauth_client))

        elif command == "jira-oauth-complete":
            if not oauth_client:
                raise DemistoException("OAuth commands are only available when using OAuth 2.0 authentication")
            code = demisto.args().get("code", "")
            if not code:
                raise DemistoException("Authorization code is required")
            return_results(oauth_complete_command(oauth_client, code))

        elif command == "jira-oauth-test":
            if not oauth_client:
                raise DemistoException("OAuth commands are only available when using OAuth 2.0 authentication")
            return_results(oauth_test_command(oauth_client))

        elif command in ("fetch-events", "jira-get-events"):
            events = get_events.run(int(demisto_params.get("max_fetch", 1000)))
            send_events_to_xsiam(events, vendor=VENDOR, product=PRODUCT)

            if events:
                demisto.setLastRun(get_events.set_next_run(events[0]))
                demisto.debug(f"Last run set to {demisto.getLastRun()}")
                if command == "jira-get-events":
                    command_results = CommandResults(
                        readable_output=tableToMarkdown(
                            "Jira Audit Records", events, removeNull=True, headerTransform=pascalToSpace
                        ),
                        raw_response=events,
                    )
                    return_results(command_results)
    except Exception as e:
        return_error(f"Failed to execute {command} command. Error: {str(e)}")


if __name__ in ("__main__", "__builtin__", "builtins"):
    main()