Phisher

KnowBE4 PhishER integration allows to pull events from PhishER system and do mutations.

Network Security · PhishER

Details

IDPhisher
ProviderVista Equity Partners
CategoryNetwork Security
From Version5.5.0
Docker Imagedemisto/python3:3.12.13.10404775
Supported ModulesAgentix XSIAM

README

KnowBe4 PhishER integration allows to pull events from PhishER system and do mutations.
This integration was integrated and tested with version 6.0.0 of XSOAR

Configure Phisher in Cortex

Parameter Description Required
Your server URL   True
API Key   True
First Fetch Time First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days, 3 months, 1 year) False
Fetch incidents   False
Fetch Limit Maximum number of alerts per fetch. Default is 50, maximum is 100. False
Incident type   False
Trust any certificate (not secure)   False
Use system proxy settings   False

Commands

You can execute these commands from the CLI, 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.

phisher-message-list


Command to get messages from PhishER

Base Command

phisher-message-list

Input

Argument Name Description Required
limit The maximum number of messages to fetch. Default is 50. Optional
query The Lucene query to search against. Optional
id ID of specific message to retrieve. If ID is given query will be ignored. Optional
include_events Whether to include all message events in the result. Possible values are: False, True. Default is False. Optional

Context Output

Path Type Description
Phisher.Message.actionStatus String Action Status
Phisher.Message.attachments String A collection of attachments associated with this message
Phisher.Message.category String The message’s category
Phisher.Message.comments String A collection of comments associated with this message.
Phisher.Message.events String A collection of events associated with this message.
Phisher.Message.from String Sender’s email
Phisher.Message.id String Unique identifier for the message.
Phisher.Message.links String A collection of links that were found in the message.
Phisher.Message.phishmlReport String The PhishML report associated with this message
Phisher.Message.pipelineStatus String Pipeline Status
Phisher.Message.reportedBy String The person who reported the message.
Phisher.Message.rawUrl String URL where to download the raw message
Phisher.Message.rules String A collection of rules associated with this message.
Phisher.Message.severity String The message’s severity
Phisher.Message.subject String Subject of the message.
Phisher.Message.tags String A collection of tags associated with this message.

Command Example

!phisher-message-list id=00a43d65-5802-4df6-9c3c-f7d2024ddb0b

Context Example

{
    "Phisher": {
        "Message": {
            "actionStatus": "IN_REVIEW",
            "attachments": [],
            "category": "CLEAN",
            "comments": [
                {
                    "body": "Folarin Balogun",
                    "createdAt": "2021-08-17T14:43:22Z"
                },
                {
                    "body": "Emile Smith Rowe 10",
                    "createdAt": "2021-08-17T14:21:17Z"
                },
                {
                    "body": "Emile Smith Rowe",
                    "createdAt": "2021-08-17T14:20:32Z"
                },
                {
                    "body": "Chupi & Toto",
                    "createdAt": "2021-08-16T12:39:15Z"
                }
            ],
            "created at": "2021-07-07T15:18:58+00:00",
            "from": "ekatsenelson@example.com",
            "id": "00a43d65-5802-4df6-9c3c-f7d2024ddb0b",
            "links":[],
            "phishmlReport": null,
            "pipelineStatus": "PROCESSED",
            "rawUrl": "https://phisher.example.com",
            "reportedBy": "ekatsenelson@example.com",
            "rules": [],
            "severity": "MEDIUM",
            "subject": "Fwd: Your next career opportunity is... Right Here!",
            "tags": [
                {
                    "name": "SIA",
                    "type": "STANDARD"
                },
                {
                    "name": "DAVY KLAASEN",
                    "type": "STANDARD"
                },
                {
                    "name": "DUSAN TADIC",
                    "type": "STANDARD"
                },
                {
                    "name": "LENO",
                    "type": "STANDARD"
                },
                {
                    "name": "BALOGUN",
                    "type": "STANDARD"
                },
                {
                    "name": "RYAN GRAVENBERGH",
                    "type": "STANDARD"
                }
            ]
        }
    }
}

Human Readable Output

Messages

ID Status Category From Severity Created At
00a43d65-5802-4df6-9c3c-f7d2024ddb0b IN_REVIEW CLEAN ekatsenelson@example.com MEDIUM 2021-07-07T15:18:58+00:00

phisher-create-comment


Adds a comment to a PhishER message

Base Command

phisher-create-comment

Input

Argument Name Description Required
id Message ID. Required
comment The comment to add. Required

Context Output

There is no context output for this command.

Command Example

!phisher-create-comment id=00a43d65-5802-4df6-9c3c-f7d2024ddb0b comment="Test Comment"

Human Readable Output

The comment was added successfully

phisher-update-message


Updates a PhishER message status. User must provide at least one argument.

Base Command

phisher-update-message

Input

Argument Name Description Required
category Message Category, can be: UNKNOWN,CLEAN,SPAM,THREAT . Possible values are: UNKNOWN, CLEAN, SPAM, THREAT. Optional
status Message Status, can be: RECEIVED,IN_REVIEW,RESOLVED. Possible values are: RECEIVED, IN_REVIEW, RESOLVED. Optional
severity Message Severity, can be: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL. Possible values are: UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL. Optional
id Message ID. Required

Context Output

There is no context output for this command.

Command Example

!phisher-update-message id=00a43d65-5802-4df6-9c3c-f7d2024ddb0b category=THREAT severity=MEDIUM status=IN_REVIEW

Human Readable Output

The message was updated successfully

phisher-tags-create


Add tags to a given message

Base Command

phisher-tags-create

Input

Argument Name Description Required
id Message ID. Required
tags Comma separated list of tags to add. Required

Context Output

There is no context output for this command.

Command Example

!phisher-tags-create id=00a43d65-5802-4df6-9c3c-f7d2024ddb0b tags="Tag1, Tag2"

Human Readable Output

The tags were updated successfully

phisher-tags-delete


Removes tags from a given message.

Base Command

phisher-tags-delete

Input

Argument Name Description Required
id Message ID. Required
tags Comma separated list of tags to remove. Required

Context Output

There is no context output for this command.

Command Example

!phisher-tags-delete id=00a43d65-5802-4df6-9c3c-f7d2024ddb0b tags="Tag2"

Human Readable Output

The tags were deleted successfully

Configuration parameters

  • url — Your server URL (required)
  • apikey
  • first_fetch — First Fetch Time
  • isFetch — Fetch incidents
  • max_fetch — Fetch Limit
  • look_back — Advanced: Minutes to look back when fetching
  • incidentType — Incident type
  • incidentFetchInterval — Incidents Fetch Interval
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (5)

  • phisher-create-comment

    Adds a comment to a PhishER message.

  • phisher-message-list

    Command to get messages from PhishER.

  • phisher-tags-create

    Add tags to a given message. If you have existing PhishER actions that would trigger for the tag that you're adding, you'll need to manually run the actions.

  • phisher-tags-delete

    Removes tags from a given message.

  • phisher-update-message

    Updates a PhishER message status. User must provide at least one argument.

import json

import knowbe4Phisher as phisher
import pytest
from CommonServerPython import CommandResults
from freezegun import freeze_time
from test_data.mock_tests import (
    create_request_test,
    events_example,
    expected_fetch,
    expected_time,
    pagination_response,
    response_fetch,
)


def util_load_json(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


client = phisher.Client(
    base_url="https://eu.test.com/graphql",
    verify=False,
    headers={"Authorization": "Bearer  + key", "Content-Type": "application/json"},
    proxy=False,
    first_fetch_time="100 days",
)


@pytest.mark.parametrize("test_input, expected", create_request_test)
def test_create_request(test_input, expected):
    """
    Given:
    - A human readable query for GQL

    When:
    - Creating GQL request

    Then:
    - Ensure that query created as expected
    """
    res = phisher.create_gql_request(test_input)
    assert res == expected


calculate_events = [
    ('\\" reported_at:[2021-07-01T16:51:45Z TO *]\\"', "13", pagination_response[0]),
    ('\\" reported_at:[2021-07-01T16:51:45Z TO *]\\"', "31", pagination_response[1]),
]


@pytest.mark.parametrize("query, expected, return_value", calculate_events)
def test_caclulate_event(mocker, query, expected, return_value):
    """
    Given:
    - A result of api response from PhishER that contains number of messages

    When:
     - When calculating number of events before fetch

    Then:
     - Ensure that the number of messages is returned as expected
    """
    mocker.patch.object(client, "phisher_gql_request", return_value=return_value)
    result = phisher.calculate_number_of_events(client, query)
    assert result == expected
    assert "X-KB4-Integration" in client._headers
    assert client._headers["X-KB4-Integration"] == "Cortex XSOAR PhishER"


test_fetch = [
    ({"last_fetch": None}, "30 days", "50", expected_fetch[0], response_fetch[0]),
    ({"last_fetch": None}, "30 days", "50", expected_fetch[1], response_fetch[1]),
]


@pytest.mark.parametrize("last_run, first_fetch, max_fetch, expected, respon", test_fetch)
def test_fetch_incidents(mocker, last_run, first_fetch, max_fetch, expected, respon):
    """
    Given:
    - Phisher Integration Parameters

    When:
    - Fetching incidents.

    Then:
    - Ensure that the incidents returned are as expected.
    """
    mocker.patch.object(client, "phisher_gql_request", return_value=respon)
    _, result = phisher.fetch_incidents(client, last_run, first_fetch, max_fetch)
    assert result == expected


# --- Lookback / EIR-14074 tests ---

MSG_A = {
    "actionStatus": "RECEIVED",
    "category": "UNKNOWN",
    "comments": [],
    "events": [
        {"causer": "null", "createdAt": "2024-01-01T10:00:00Z", "eventType": "CREATED", "id": "evt-a1", "triggerer": "null"},
    ],
    "from": "a@example.com",
    "id": "msg-a",
    "phishmlReport": None,
    "pipelineStatus": "PROCESSED",
    "reportedAt": "2024-01-01T09:59:55Z",
    "severity": "UNKNOWN_SEVERITY",
    "subject": "Message A",
    "tags": [],
}

MSG_B = {
    "actionStatus": "RECEIVED",
    "category": "UNKNOWN",
    "comments": [],
    "events": [
        {"causer": "null", "createdAt": "2024-01-01T10:05:00Z", "eventType": "CREATED", "id": "evt-b1", "triggerer": "null"},
    ],
    "from": "b@example.com",
    "id": "msg-b",
    "phishmlReport": None,
    "pipelineStatus": "PROCESSED",
    "reportedAt": "2024-01-01T10:04:55Z",
    "severity": "UNKNOWN_SEVERITY",
    "subject": "Message B",
    "tags": [],
}


def _gql_response(messages):
    return {
        "data": {
            "phisherMessages": {"nodes": messages, "pagination": {"page": 1, "pages": 1, "per": 50, "totalCount": len(messages)}}
        }
    }


@freeze_time("2024-01-01T11:00:00Z")
def test_fetch_incidents_first_run(mocker):
    """
    Given:
    - No prior run (empty last_run), first_fetch of 7 days, 2 messages returned from API

    When:
    - fetch_incidents is called

    Then:
    - Both incidents are emitted, next_run has 'time' and 'found_incident_ids' containing both message ids
    """
    mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([MSG_A, MSG_B]))
    next_run, incidents = phisher.fetch_incidents(client, {}, "7 days", 50)
    assert len(incidents) == 2
    assert "time" in next_run
    assert "found_incident_ids" in next_run
    assert "msg-a" in next_run["found_incident_ids"]
    assert "msg-b" in next_run["found_incident_ids"]


@freeze_time("2024-01-01T11:00:00Z")
def test_fetch_incidents_dedup_via_found_ids(mocker):
    """
    Given:
    - last_run contains msg-a in found_incident_ids, API returns both msg-a and msg-b

    When:
    - fetch_incidents is called

    Then:
    - Only msg-b is emitted (msg-a is deduped), found_incident_ids still includes msg-b
    """
    last_run = {"time": "2024-01-01T10:00:00Z", "found_incident_ids": {"msg-a": 1704067200}}
    mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([MSG_A, MSG_B]))
    next_run, incidents = phisher.fetch_incidents(client, last_run, "7 days", 50)
    assert len(incidents) == 1
    assert incidents[0]["dbotMirrorId"] == "msg-b"
    assert "msg-b" in next_run["found_incident_ids"]


@freeze_time("2024-01-01T11:00:00Z")
def test_fetch_incidents_late_arrival_recovered(mocker):
    """
    Given:
    - last_run time is T-30min (10:30Z), look_back=60
    - API returns a message with created_at = 2024-01-01T10:05:00Z (T-55min, before last_run["time"])
    - That message is NOT in found_incident_ids

    When:
    - fetch_incidents is called with look_back=60

    Then:
    - The late-arriving message IS emitted (lookback expanded the start window to T-60min)
    """
    last_run = {"time": "2024-01-01T10:30:00Z", "found_incident_ids": {}}
    late_msg = {
        "actionStatus": "RECEIVED",
        "category": "UNKNOWN",
        "comments": [],
        "events": [
            {
                "causer": "null",
                "createdAt": "2024-01-01T10:05:00Z",
                "eventType": "CREATED",
                "id": "evt-late",
                "triggerer": "null",
            },
        ],
        "from": "late@example.com",
        "id": "msg-late",
        "phishmlReport": None,
        "pipelineStatus": "PROCESSED",
        "severity": "UNKNOWN_SEVERITY",
        "subject": "Late message",
        "tags": [],
    }
    mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([late_msg]))
    next_run, incidents = phisher.fetch_incidents(client, last_run, "7 days", 50, look_back=60)
    assert len(incidents) == 1
    assert incidents[0]["dbotMirrorId"] == "msg-late"


@freeze_time("2024-01-01T11:00:00Z")
def test_fetch_incidents_lookback_zero_no_overlap(mocker):
    """
    Given:
    - last_run time is 10:30Z, look_back=0
    - A spy captures the GQL payload sent to phisher_gql_request

    When:
    - fetch_incidents is called with look_back=0

    Then:
    - The GQL payload contains 'reported_at:{2024-01-01T10:30:00Z TO' (no window expansion)
    """
    last_run = {"time": "2024-01-01T10:30:00Z", "found_incident_ids": {}}
    spy = mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([]))
    phisher.fetch_incidents(client, last_run, "7 days", 50, look_back=0)
    call_arg = spy.call_args[0][0]
    assert "reported_at:{2024-01-01T10:30:00Z TO" in call_arg


@freeze_time("2024-01-01T11:00:00Z")
def test_fetch_incidents_max_fetch_truncates(mocker):
    """
    Given:
    - API returns 5 messages, max_fetch=2

    When:
    - fetch_incidents is called

    Then:
    - Only 2 incidents are emitted
    """
    msgs = [
        {
            **MSG_A,
            "id": f"msg-{i}",
            "subject": f"Msg {i}",
            "events": [
                {
                    "causer": "null",
                    "createdAt": f"2024-01-01T10:0{i}:00Z",
                    "eventType": "CREATED",
                    "id": f"evt-{i}",
                    "triggerer": "null",
                }
            ],
        }
        for i in range(5)
    ]
    mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response(msgs))
    _next_run, incidents = phisher.fetch_incidents(client, {}, "7 days", 2)
    assert len(incidents) == 2


@freeze_time("2024-01-01T11:00:00Z")
def test_fetch_incidents_legacy_last_fetch_migration(mocker):
    """
    Given:
    - last_run has legacy shape {"last_fetch": "2024-01-01T10:00:00Z"} (pre-lookback upgrade)
    - API returns msg-a

    When:
    - fetch_incidents is called

    Then:
    - msg-a is emitted (legacy time is used, not first_fetch fallback)
    - next_run has the new dict shape with 'time' and 'found_incident_ids'
    """
    # legacy state gets migrated in fetch_incidents_command; fetch_incidents itself
    # receives the already-migrated dict, so we simulate that here
    legacy_migrated = {"time": "2024-01-01T10:00:00Z", "found_incident_ids": {}}
    mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([MSG_A]))
    next_run, incidents = phisher.fetch_incidents(client, legacy_migrated, "7 days", 50)
    assert len(incidents) == 1
    assert "time" in next_run
    assert "found_incident_ids" in next_run


@freeze_time("2024-01-01T11:00:00Z")
def test_fetch_incidents_query_uses_window(mocker):
    """
    Given:
    - last_run time is 10:30Z, look_back=0
    - now is frozen at 11:00Z

    When:
    - fetch_incidents is called

    Then:
    - GQL payload uses a closed window 'reported_at:{10:30:00Z TO 11:00:00Z}', not open-ended 'TO *'
    """
    last_run = {"time": "2024-01-01T10:30:00Z", "found_incident_ids": {}}
    spy = mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([]))
    phisher.fetch_incidents(client, last_run, "7 days", 50, look_back=0)
    call_arg = spy.call_args[0][0]
    assert "reported_at:{2024-01-01T10:30:00Z TO 2024-01-01T11:00:00Z}" in call_arg
    assert "TO *" not in call_arg


@freeze_time("2024-01-01T11:00:00Z")
def test_fetch_incidents_cursor_uses_reported_at(mocker):
    """
    Given:
    - A message with reportedAt earlier than its CREATED event createdAt

    When:
    - fetch_incidents is called

    Then:
    - created_at_cursor is set to reportedAt, not createdAt, keeping the
      cursor coherent with the reported_at Lucene query field
    """
    last_run = {"time": "2024-01-01T09:00:00Z", "found_incident_ids": {}}
    mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([MSG_A]))
    next_run, incidents = phisher.fetch_incidents(client, last_run, "7 days", 50)
    assert len(incidents) == 1
    raw = json.loads(incidents[0]["rawJSON"])
    assert raw["created_at_cursor"] == "2024-01-01T09:59:55Z"  # reportedAt, not createdAt (10:00:00Z)


@freeze_time("2024-01-01T11:00:00Z")
def test_test_module_returns_ok_when_no_messages(mocker):
    """
    Given:
    - A valid connection to PhishER that returns zero messages

    When:
    - test-module is called (e.g. from XSOAR integration config)

    Then:
    - "ok" is returned — empty inbox is not a failure
    """
    mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([]))
    mocker.patch("knowbe4Phisher.demisto").getLastRun.return_value = {}
    mocker.patch.object(client, "max_fetch", 50)
    result = phisher.test_module(client)
    assert result == "ok"


@freeze_time("2024-01-01T11:00:00Z")
def test_test_module_returns_ok_when_messages_exist(mocker):
    """
    Given:
    - A valid connection to PhishER that returns messages

    When:
    - test-module is called

    Then:
    - "ok" is returned
    """
    mocker.patch.object(client, "phisher_gql_request", return_value=_gql_response([MSG_A]))
    mocker.patch("knowbe4Phisher.demisto").getLastRun.return_value = {}
    mocker.patch.object(client, "max_fetch", 50)
    result = phisher.test_module(client)
    assert result == "ok"


def test_time_creation():
    """
    Given:
    - Events example from Phisher Response

    When:
    - when fetching messages from Phisher - fetch or list of all messages

    Then:
    - Ensure that the event time is extracted as expected
    """
    result = phisher.get_created_time(events_example)
    assert result == expected_time


mock_responses = util_load_json("test_data/test_responses.json")
command_results = util_load_json("test_data/mock_responses.json")


@pytest.mark.parametrize(
    "function_to_test, function_to_mock, args, key",
    [
        (phisher.phisher_message_list_command, "phisher_gql_request", {}, "message_list_all"),
    ],
)
def test_commands_with_results(mocker, function_to_test, function_to_mock, args, key):
    expected_res = mock_responses[key]
    mocker.patch.object(client, function_to_mock, return_value=command_results[key])
    result: CommandResults = function_to_test(client, args)
    assert result.outputs == expected_res


@pytest.mark.parametrize(
    "function_to_test, function_to_mock, args, key",
    [
        (
            phisher.phisher_create_comment_command,
            "phisher_gql_request",
            {"id": "cff35e34-aeb6-4263-b592-c68fc03ea7cb", "comment": "Infinity Test"},
            "create_comment",
        ),
        (
            phisher.phisher_update_message_command,
            "phisher_gql_request",
            {"id": "cff35e34-aeb6-4263-b592-c68fc03ea7cb", "category": "SPAM", "status": "RESOLVED", "severity": "HIGH"},
            "update_message",
        ),
        (
            phisher.phisher_create_tags_command,
            "phisher_gql_request",
            {"id": "cff35e34-aeb6-4263-b592-c68fc03ea7cb", "tags": "Test Tag"},
            "create_tags",
        ),
        (
            phisher.phisher_delete_tags_command,
            "phisher_gql_request",
            {"id": "cff35e34-aeb6-4263-b592-c68fc03ea7cb", "tags": "Test Tag"},
            "delete_tags",
        ),
    ],
)
def test_commands_no_results(mocker, function_to_test, function_to_mock, args, key):
    expected_res = mock_responses[key]
    mocker.patch.object(client, function_to_mock, return_value=command_results[key])
    result = function_to_test(client, args)
    assert result == expected_res