OPSWAT-Metadefender V2

multi-scanning engine uses 30+ anti-malware engines to scan files for threats, significantly increasing malware detection.

Data Enrichment & Threat Intelligence · OPSWAT-Metadefender

Details

IDOPSWAT-Metadefender V2
ProviderOPSWAT Inc
CategoryData Enrichment & Threat Intelligence
From Version5.0.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

multi-scanning engine uses 30+ anti-malware engines to scan files for threats, significantly increasing malware detection.
This integration was integrated and tested with version 5.0.0 of OPSWAT-Metadefender V2.

Configure OPSWAT-Metadefender v2 in Cortex

Parameter Required
Server URL (e.g. http://localhost:8008/metascan_rest/) True
API Key - Needed in cloud based solutions False
API Key - Needed in cloud based solutions False
Cloud based False
Trust any certificate (not secure) False
Use system proxy settings False
The high threshold False
The low threshold 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.

opswat-scan-file


Scan file in OPSWAT

Base Command

opswat-scan-file fileId=1191@302

Input

Argument Name Description Required
fileId Entry id of a file in XSOAR. Required
scanRule Name of the Rule to use for scanning (Optional). Optional

Context Output

Path Type Description
OPSWAT.FileName string OPSWAT file name to scan
OPSWAT.ScanId string OPSWAT scan id of the scan

opswat-hash


Check file hash on OPSWAT

Base Command

opswat-hash hash=cc273fe9d442850fa18c31c88c823e07

Input

Argument Name Description Required
hash File hash (Can be any hash type). Required

Context Output

There is no context output for this command.

opswat-scan-result


Get OPSWAT result

Base Command

opswat-scan-result id=123

Input

Argument Name Description Required
id OPSWAT scan id. Required

Context Output

There is no context output for this command.

opswat-sanitization-result


Get OPSWAT sanitization result (Requires CDR feature).
In order to have sanitized versions of the file, the DeepCDR feature needs to be enabled in the Workflow rule used for scanning the file.

Base Command

opswat-sanitization-result

Input

Argument Name Description Required
id OPSWAT scan id. Required

Context Output

There is no context output for this command.

Configuration parameters

  • url — Server URL (e.g. http://localhost:8008/metascan_rest/) (required)
  • api_key — API Key - Needed in cloud based solutions
  • api_key_creds
  • cloud — Cloud based
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • highPercnt — The high threshold
  • lowPercnt — The low threshold

Commands (4)

  • opswat-hash

    Check file hash on OPSWAT.

  • opswat-sanitization-result

    Get OPSWAT sanitization result (Requires CDR feature).

  • opswat-scan-file

    Scan file in OPSWAT.

  • opswat-scan-result

    Get OPSWAT result.

from CommonServerPython import *
import pytest
import tempfile
import requests

BASE_URL = "http://localhost:8008/metascan_rest/"
SCAN_RESULTS_RES_1: dict = {}
SCAN_RESULTS_RES_2: dict = {
    "file_info": {"file_type_description": "type_desc", "display_name": "display_name", "md5": "some_md5_hash"},
    "process_info": {"progress_percentage": 50},
    "scan_results": {
        "total_avs": 100,
        "scan_all_result_a": "scan_all_result_a",
        "scan_all_result_i": 100,
        "scan_details": {"key": {"def_time": "1/1/2023", "threat_found": "threat"}},
    },
}
HASH_INFO_RES_1: dict = {}
HASH_INFO_RES_2: dict = {
    "file_info": {"display_name": "display_name", "file_type_description": "type_desc"},
    "scan_results": {
        "scan_all_result_a": "scan_all_result_a",
        "total_detected_avs": 100,
        "total_avs": 100,
        "scan_details": {"key": {"def_time": "1/1/2023", "threat_found": "threat"}},
    },
}


class MockResponse:
    def __init__(self, json_data, status_code, content=None):
        self.json_data = json_data
        self.status_code = status_code
        self.content = content

    def json(self):
        return self.json_data

    def content(self):
        return self.content


# This method will be used by the mock to replace requests.post
def mocked_requests_post(*args, **kwargs):
    file_name = kwargs.get("headers", {}).get("filename", "")
    try:
        if type(file_name) is str:
            file_name.encode("latin-1")
        else:
            file_name.decode("latin-1")
        return MockResponse({"data_id": "mock_id"}, 200)
    except Exception as e:
        return MockResponse(str(e), 404)


@pytest.mark.parametrize(
    "file_name, data, expected_md_results",
    [
        (
            "2022年年年年年.docx",
            "年年年年年",
            "# OPSWAT-Metadefender\nThe file has been successfully submitted to scan.\nScan id: mock_id\n",
        )
    ],
)
def test_scan_file_command(mocker, file_name, data, expected_md_results):
    """
    Given:
    - File_name and content to mock file_entry.
    - case 1: a docx file with chinese letters in the name and content
    When:
    - Calling scan_file_command.
    Then:
    - Ensures the String type was parsed correctly and that the entry was generated correctly.
    - case 1: Ensures the request didn't fail due to letters parsing issue and that the entry was generated correctly.
    """
    with tempfile.TemporaryDirectory() as temp_dir:
        file_path = f"{temp_dir}/{file_name}"
        with open(file_path, "w") as f:
            f.write(data)
        mocker.patch.object(demisto, "getFilePath", return_value={"path": file_path, "name": file_name})
        mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})
        mocker.patch.object(requests, "post", side_effect=mocked_requests_post)
        mocker.patch.object(demisto, "args", return_value={"fileId": "1191@302", "scanRule": "Test"})
        mocker.patch.object(demisto, "results")

        from OPSWATMetadefenderV2 import scan_file_command

        scan_file_command()

    entry = demisto.results.call_args[0][0]
    ec_results = entry.get("EntryContext", {}).get("OPSWAT", {})
    assert entry.get("HumanReadable") == expected_md_results
    assert ec_results.get("ScanId") == "mock_id"
    assert ec_results.get("FileName") == file_name


# This method will be used by the mock to replace requests.get
def mocked_requests_get(*args, **kwargs):
    url = args[0]
    data = {}
    if "file" in url:
        id = url[-1]
        if id == "1":
            data = SCAN_RESULTS_RES_1
        elif id == "2":
            data = SCAN_RESULTS_RES_2
    elif "hash" in url:
        hash = url[-32:]
        if hash == "9d59494ca97bac09a2fb22188b03961f":
            data = HASH_INFO_RES_1
        elif hash == "9d59494ca97bac09a2fb22188b03961s":
            data = HASH_INFO_RES_2
    return MockResponse(data, 200)


@pytest.mark.parametrize(
    "id, expected_md_results",
    [
        ("1", "# OPSWAT-Metadefender\n### Results for scan id 1\nNo results for this id\n"),
        (
            "2",
            "# OPSWAT-Metadefender\n### Results for scan id 2\n### The scan proccess is in progrees (done: 50%) \nFile "
            "name: display_name\nScan result:scan_all_result_a\nDetected AV: 100/100\nAV Name|Def Time|Threat Name Found\n"
            "---|---|---\nkey|1/1/2023|threat\n",
        ),
    ],
)
def test_get_scan_result_command(mocker, id, expected_md_results):
    """
    Given:
    - a file id.
    - case 1: an id that return empty response
    - case 2: an id that return a non empty response
    When:
    - Running get_scan_result_command.
    Then:
    - Ensures the HumanReadable in the result entry was generated correctly.
    - case 1: Ensures the human readable contains a no results message.
    - case 2: Ensures the table in the human readable was generated correctly.
    """
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})
    mocker.patch.object(requests, "get", side_effect=mocked_requests_get)
    mocker.patch.object(demisto, "args", return_value={"id": id})
    mocker.patch.object(demisto, "results")

    from OPSWATMetadefenderV2 import get_scan_result_command

    get_scan_result_command()
    entry = demisto.results.call_args[0][0]
    assert entry.get("HumanReadable") == expected_md_results


@pytest.mark.parametrize(
    "hash, expected_md_results",
    [
        ("9d59494ca97bac09a2fb22188b03961f", "# OPSWAT-Metadefender\nNo results for hash 9d59494ca97bac09a2fb22188b03961f\n"),
        (
            "9d59494ca97bac09a2fb22188b03961s",
            "# OPSWAT-Metadefender\nFile name: display_name\nFile description: type_desc\n"
            "Scan result: scan_all_result_a\nDetected AV: 100/100\nAV Name|Def Time|Threat Name Found\n"
            "---|---|---\nkey|1/1/2023|threat\n",
        ),
    ],
)
def test_get_hash_info_command(mocker, hash, expected_md_results):
    """
    Given:
    - a file hash.
    - case 1: a hash that return empty response
    - case 2: a hash that return a non empty response
    When:
    - Running get_hash_info_command.
    Then:
    - Ensures the HumanReadable in the result entry was generated correctly.
    - case 1: Ensures the human readable contains a no results message.
    - case 2: Ensures the table in the human readable was generated correctly.
    """
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})
    mocker.patch.object(requests, "get", side_effect=mocked_requests_get)
    mocker.patch.object(demisto, "args", return_value={"hash": hash})
    mocker.patch.object(demisto, "results")

    from OPSWATMetadefenderV2 import get_hash_info_command

    get_hash_info_command()
    entry = demisto.results.call_args[0][0]
    assert entry.get("HumanReadable") == expected_md_results


def test_get_sanitized_file_command(mocker):
    """
    Given:
    - a file id.
    When:
    - Running get_sanitized_file_command.
    Then:
    - Ensures that sanitized file was created.
    """
    mocker.patch.object(demisto, "args", return_value={"id": "1"})
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})
    mocker.patch(
        "OPSWATMetadefenderV2.get_scan_result",
        return_value={
            "process_info": {"post_processing": {"converted_destination": "sanitized.pdf", "actions_ran": "Sanitized"}}
        },
    )
    mocker.patch("OPSWATMetadefenderV2.get_sanitized_file", return_value=b"sanitized file content")
    mocker.patch.object(demisto, "results")
    from OPSWATMetadefenderV2 import get_sanitized_file_command

    get_sanitized_file_command()
    entry = demisto.results.call_args[0][0]
    assert entry.get("File") == "sanitized.pdf"


def test_get_sanitized_file_fail_command(mocker):
    """
    Given:
    - a file id.
    When:
    - Running get_sanitized_file_command.
    Then:
    - Ensures that sanitized file wasn't created and warning was created.
    """
    mocker.patch.object(demisto, "args", return_value={"id": "1"})
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})
    mocker.patch("OPSWATMetadefenderV2.get_scan_result", return_value={"process_info": {}})
    mocker.patch("OPSWATMetadefenderV2.get_sanitized_file", return_value=b"sanitized file content")
    mocker.patch.object(demisto, "results")
    from OPSWATMetadefenderV2 import get_sanitized_file_command

    get_sanitized_file_command()
    entry = demisto.results.call_args[0][0]
    assert entry == {"Type": 11, "ContentsFormat": "text", "Contents": "No sanitized file."}


@pytest.mark.parametrize(
    "method, url_suffix, file_name, parse_json, scan_rule, expected_result, expected_status_code",
    [
        # # Test case 1: Basic GET request
        ("GET", "test", None, True, None, {"success": True}, 200),
        # # Test case 2: GET request with scan rule
        ("GET", "hash/123", None, True, "custom_rule", {"file_info": {"md5": "123"}}, 200),
        # # Test case 3: POST request
        ("POST", "file", None, True, None, {"data_id": "scan123"}, 200),
        # # Test case 5: File upload
        ("POST", "file", "test_file.txt", True, None, {"data_id": "file123"}, 200),
        # # Test case 6: Non-JSON response
        ("GET", "file/converted/123", None, False, None, b"test file content", 200),
    ],
)
def test_http_req(mocker, method, url_suffix, file_name, parse_json, scan_rule, expected_result, expected_status_code):
    """
    Given:
    - Different HTTP request scenarios for the http_req function
    - Case 1: Basic GET request
    - Case 2: GET request with custom scan rule
    - Case 3: POST request
    - Case 5: File upload
    - Case 6: Non-JSON response

    When:
    - Calling http_req with various parameters

    Then:
    - Ensures the function handles different HTTP methods correctly
    - Ensures proper headers are set based on parameters
    - Ensures file uploads are handled correctly
    - Ensures response parsing works as expected
    """
    # Setup mocks
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL, "api_key_creds": {"password": "test_api_key"}})

    # Mock file operations
    mock_open = mocker.mock_open(read_data=b"test file content")
    mocker.patch("builtins.open", mock_open)

    # Create mock response
    mock_response = MockResponse(expected_result, expected_status_code, b"test file content")

    # Mock requests methods
    if method.upper() == "GET":
        mocker.patch.object(requests, "get", return_value=mock_response)
    elif method.upper() == "POST":
        mocker.patch.object(requests, "post", return_value=mock_response)

    # Import the function after mocking
    from OPSWATMetadefenderV2 import http_req

    # Call the function
    result = http_req(method=method, url_suffix=url_suffix, file_name=file_name, parse_json=parse_json, scan_rule=scan_rule)

    # Verify the result
    assert result == expected_result

    # Verify the correct request method was called with proper parameters
    if method.upper() == "GET":
        requests_mock = requests.get
    elif method.upper() == "POST":
        requests_mock = requests.post

    # Verify URL
    expected_url = BASE_URL + url_suffix
    assert requests_mock.call_args[0][0] == expected_url

    # Verify headers
    headers = requests_mock.call_args[1]["headers"]
    assert headers["Accept"] == "application/json"

    # Verify scan rule if provided
    if scan_rule:
        assert headers["rule"] == scan_rule.encode("utf-8")

    # Verify file upload headers if file_name is provided
    if file_name:
        assert headers["content-type"] == "application/octet-stream"
        assert headers["filename"] == file_name.encode("utf-8")
        assert mock_open.call_args[0][0] == file_name
        assert requests_mock.call_args.kwargs["data"].read() == b"test file content"


@pytest.mark.parametrize(
    "status_code, expected_error",
    [
        (400, "Request failed, got status 400: bad request. Check command parameters"),
        (401, "Request failed, got status 401: unauthorized. Check your API Key"),
        (404, "Request failed, got status 404: not found. Check integration URL Address"),
        (500, "Request failed got status 500"),
    ],
)
def test_http_req_error_handling(mocker, status_code, expected_error):
    """
    Given:
    - Different HTTP error status codes
    - Case 1: 400 Bad Request
    - Case 2: 401 Unauthorized
    - Case 3: 404 Not Found
    - Case 4: 500 Server Error (not in predefined error codes)

    When:
    - Calling http_req and receiving an error status code

    Then:
    - Ensures the function handles error codes correctly
    - Ensures appropriate error messages are returned
    """
    # Setup mocks
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})

    # Create mock response with error status
    mock_response = MockResponse({}, status_code)
    mocker.patch.object(requests, "get", return_value=mock_response)

    # Mock return_error function
    mock_return_error = mocker.patch("OPSWATMetadefenderV2.return_error")

    # Import the function after mocking
    from OPSWATMetadefenderV2 import http_req

    # Call the function
    http_req(method="GET", url_suffix="test")

    # Verify return_error was called with the expected error message
    mock_return_error.assert_called_once_with(expected_error)


def test_http_req_unsupported_method(mocker):
    """
    Given:
    - An unsupported HTTP method

    When:
    - Calling http_req with an unsupported method

    Then:
    - Ensures the function handles unsupported methods correctly
    - Ensures appropriate error message is returned
    """
    # Setup mocks
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})

    # Mock return_error function
    mock_return_error = mocker.patch("OPSWATMetadefenderV2.return_error")

    # Import the function after mocking
    from OPSWATMetadefenderV2 import http_req

    # Call the function with unsupported method
    result = http_req(method="PUT", url_suffix="test")

    # Verify return_error was called with the expected error message
    mock_return_error.assert_called_once_with("Got unsupporthed http method: PUT")

    # Verify the function returns an empty string
    assert result == ""


@pytest.mark.parametrize(
    "input_name, expected_basename",
    [
        ("/tmp/evil/../../../etc/passwd", "passwd"),
        ("simple_file.txt", "simple_file.txt"),
        ("/absolute/path/to/report.pdf", "report.pdf"),
        ("../relative/path/data.csv", "data.csv"),
        ("no_extension", "no_extension"),
    ],
)
def test_scan_file_uses_basename(mocker, input_name, expected_basename):
    """
    Given:
        - A file entry with various name formats including directory path components.
    When:
        - Calling scan_file.
    Then:
        - Verify that only the basename of the file name is used.
    """
    from pathlib import Path

    import demistomock as demisto

    mocker.patch.object(
        demisto,
        "getFilePath",
        return_value={"path": "README.md", "name": input_name},
    )
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})
    mock_copy = mocker.patch("shutil.copy")
    mocker.patch("os.remove")
    mocker.patch(
        "OPSWATMetadefenderV2.http_req",
        return_value={"data_id": "test123"},
    )

    from OPSWATMetadefenderV2 import scan_file

    _, file_name = scan_file("entry1")

    # Verify the file name returned is the basename only
    assert file_name == expected_basename
    assert Path(file_name).name == file_name
    # Verify shutil.copy was called with the sanitized basename
    copy_call_args = mock_copy.call_args[0]
    assert copy_call_args[1] == expected_basename


def test_scan_file_cleanup_handles_os_error(mocker):
    """
    Given:
        - A file entry where os.remove raises an OSError during cleanup.
    When:
        - Calling scan_file.
    Then:
        - Verify that the OSError is suppressed and the result is returned.
    """
    import demistomock as demisto

    mocker.patch.object(
        demisto,
        "getFilePath",
        return_value={"path": "README.md", "name": "test_file.txt"},
    )
    mocker.patch.object(demisto, "params", return_value={"url": BASE_URL})
    mocker.patch("shutil.copy")
    mocker.patch("os.remove", side_effect=OSError("File not found"))
    mock_debug = mocker.patch.object(demisto, "debug")
    mocker.patch(
        "OPSWATMetadefenderV2.http_req",
        return_value={"data_id": "test123"},
    )

    from OPSWATMetadefenderV2 import scan_file

    result, file_name = scan_file("entry1")

    # Verify the function completed successfully despite the OSError
    assert result == {"data_id": "test123"}
    assert file_name == "test_file.txt"
    # Verify debug was called with the cleanup failure message
    mock_debug.assert_called_once_with("Could not remove temporary file: test_file.txt")