Twinwave

TwinWave’s threat analysis platform analyzes both URLs and files to detect credential phishing and malware threats. Our platform automatically navigates complex attack chains that attackers put in front of threats in order to evade analysis. In addition to detecting threats, the TwinWave platform generates actionable intelligence for threat hunting and other activities.

Data Enrichment & Threat Intelligence · Twinwave

Details

IDTwinwave
ProviderCrowdStrike
CategoryData Enrichment & Threat Intelligence
From Version6.0.0
Docker Imagedemisto/python3:3.12.8.3296088
Supported ModulesAgentix XSIAM

README

TwinWave’s threat analysis platform analyzes both URLs and files to detect credential phishing and malware threats. Our platform automatically navigates complex attack chains that attackers put in front of threats in order to evade analysis. In addition to detecting threats, the TwinWave platform generates actionable intelligence for threat hunting and other activities.

Supported Cortex XSOAR versions: 6.0.0 and later.

Configure Twinwave in Cortex

Parameter Description Required
isFetch Fetch incidents False
incidentType Incident type False
api-token Twinwave API token True
first_fetch Number of jobs to first fetch False
max_fetch   False
source Filter incidents by submission source. False
username Filter UI incidents by username. Exact match only. (Cannot use if source is all or api) False
proxy Use system proxy settings False
insecure Trust any certificate (not secure) 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.

twinwave-submit-url


Submit New URL for Scanning.

Notice: Submitting indicators using this command might make the indicator data publicly available. See the vendor’s documentation for more details.

Base Command

twinwave-submit-url

Input

Argument Name Description Required
url The target URL to visit and analyze. Unlike the UI, the API does not automatically un-defang the submitted URL. Required
engines Array of strings (EngineName). List of engines to be used during the analysis. If you’d like to use the default Engines for your account, omit this field or specify the empty array []. . Optional
parameters Optional list of parameters to customize behavior during analysis of the job. (E.g., passwords for archives.) {“archive_document_password”: “”, “decode_rewritten_urls”: “true/false”}. Optional
priority The job’s priority relative to other jobs. Jobs with a lower priority value are processed before those with a higher value. (e.g., a priority=1 job will be processed before a priority=2 job.) Valid priority values are between 1 and 255. You may omit this field, in which case a default priority (10) is used. Default is 10. Optional
profile An optional profile name that defines the analysis behavior to be used during the analysis for this job. Profiles names map to behaviors like identifying what collection of engines will be used. If no profile name is submitted the system will use the default profile. Optional

Context Output

Path Type Description
Twinwave.Submissions.JobID Unknown Job ID

twinwave-submit-file


Submit File for Scanning.

Notice: Submitting indicators using this command might make the indicator data publicly available. See the vendor’s documentation for more details.

Base Command

twinwave-submit-file

Input

Argument Name Description Required
entry_id The entry id of the File. Required
priority The job’s priority relative to other jobs. Jobs with a lower priority value are processed before those with a higher value. (e.g., a priority=1 job will be processed before a priority=2 job.) Valid priority values are between 1 and 255. You may omit this field, in which case a default priority (10) is used. Default is 10. Optional
profile An optional profile name that defines the analysis behavior to be used during the analysis for this job. Profiles names map to behaviors like identifying what collection of engines will be used. If no profile name is submitted the system will use the default profile. Optional

Context Output

Path Type Description
Twinwave.Submissions.JobID Unknown Job ID

twinwave-resubmit-job


Resubmit a Job

Base Command

twinwave-resubmit-job

Input

| Argument Name | Description | Required |
| — | — | — |

Context Output

Path Type Description
Twinwave.Submissions.JobID Unknown Job ID

twinwave-get-job-summary


Get Job Summary

Base Command

twinwave-get-job-summary

Input

Argument Name Description Required
job_id the job ID. Required

Context Output

Path Type Description
Twinwave.JobSummary Unknown Twinwave Job Summary
Twinwave.JobSummary.ID Unknown Job ID
Twinwave.JobSummary.Tasks.ID Unknown Task ID
Twinwave.JobSummary.Tasks.JobID Unknown Job ID associated to the task

twinwave-get-job-normalized-forensics


Get a Job’s Normalized Forensics

Base Command

twinwave-get-job-normalized-forensics

Input

Argument Name Description Required
job_id The job ID. Required

Context Output

Path Type Description
Twinwave.JobNormalizedForensics Unknown Twinwave Job Normalized Forensics
Twinwave.JobNormalizedForensics.JobID Unknown Job ID

twinwave-get-task-normalized-forensics


Get a Task’s Normalized Forensics

Base Command

twinwave-get-task-normalized-forensics

Input

Argument Name Description Required
job_id The job ID. Required
task_id The task ID. Required

Context Output

Path Type Description
Twinwave.TaskNormalizedForensics Unknown Twinwave Task Normalized Forensics
Twinwave.TaskNormalizedForensics.TaskID Unknown Task ID
Twinwave.TaskNormalizedForensics.JobID Unknown Job ID

twinwave-get-task-raw-forensics


Get a Task’s Raw Forensics

Base Command

twinwave-get-task-raw-forensics

Input

Argument Name Description Required
job_id The job ID. Required
task_id The task ID. Required

Context Output

Path Type Description
Twinwave.TaskRawForensics Unknown Twinwave Task Raw Forensics
Twinwave.TaskRawForensics.JobID Unknown Job ID
Twinwave.TaskRawForensics.TaskID Unknown Task ID

twinwave-download-submitted-resource


Download the Submitted Resource.

Download a password-protected Zip archive of the Resource. Use the password ‘infected’ to decrypt the archive.

All Resources discovered during the analysis are available for download via this endpoint. To get the list of SHA256s for the Job’s Resources, see The Resources array from Get a Job Summary.

Base Command

twinwave-download-submitted-resource

Input

Argument Name Description Required
job_id The job ID. Required
sha256 The File sha256. Required

Context Output

Path Type Description
File.Name Unknown Name of the file
File.EntryID Unknown Entry ID of the file

twinwave-get-engines


List Available Engines

Base Command

twinwave-get-engines

Input

| Argument Name | Description | Required |
| — | — | — |

Context Output

Path Type Description
Twinwave.Engines Unknown Available Engines
Twinwave.Engines.Name Unknown Name of the engine
Twinwave.Engines.DefaultEnabled Unknown Default Enabled (True/False)
Twinwave.Engines.SupportedTypes Unknown Supported Types

twinwave-search-across-jobs-and-resources


Search Across Jobs and Resources

Base Command

twinwave-search-across-jobs-and-resources

Input

Argument Name Description Required
term Specify the string to search for in the specified field. (E.g. .exe or example.com). Optional
field Enum: “filename” “url” “tag” “sha256” “md5”. Optional
type Enum: “exact” “substring”. Optional
count Specify the maximum number of results to be returned. This has a hard limit of 100; specifying a number greater than that will result in a 400 Bad Request and the search will not be performed. Optional
shared_only Specify true to only search across Jobs (and their Resources) which have been shared. Optional
submitted_by Specify a username or part of a username (e.g. alice@example.com or alice) to only search across Jobs (and their Resources) submitted by the matching user. Optional
timeframe Specify the maximum number of days back to search for results. Specify 0 for no limit. For example, setting this to 7 returns results within the last week. Optional
page The page for which you want results. This defaults to 1 the first page. See HasNext in the response of your search to know whether or not there are more pages for your search criteria. Optional

Context Output

Path Type Description
Twinwave.JobsAndResources Unknown Jobs and Resources
Twinwave.JobsAndResources.Jobs Unknown Job Details
Twinwave.JobsAndResources.Jobs.ID Unknown Job ID

twinwave-get-temp-artifact-url


Get a Temporary Artifact URL

Base Command

twinwave-get-temp-artifact-url

Input

Argument Name Description Required
path Throughout the analysis of a Resource, a variety of Artifacts may be generated. These include things like Screenshots, PCAPs, HAR files, etc. This API endpoint generates a temporary URL that can be used to download the contents of an artifact.

After making a call to this endpoint, the URL field will contain a link to a signed URL for the desired Artifact. This link has a limited lifetime, so upon receiving it, you should immediately make a GET request to retrieve the actual Artifact.
Required

Context Output

Path Type Description
Twinwave.TempArtifactURL.URL Unknown Temporary URL

Configuration parameters

  • isFetch — Fetch incidents
  • incidentType — Incident type
  • incidentFetchInterval — Incidents Fetch Interval
  • api-token — Twinwave API token (required)
  • first_fetch — Number of jobs to first fetch
  • max_fetch — Max Fetch
  • source — Filter incidents by submission source.
  • username — Filter UI incidents by username. Exact match only. (Cannot use if source is all or api)
  • proxy — Use system proxy settings
  • insecure — Trust any certificate (not secure)

Commands (11)

  • twinwave-download-submitted-resource

    Download the Submitted Resource. Download a password-protected Zip archive of the Resource. Use the password 'infected' to decrypt the archive. All Resources discovered during the analysis are available for download via this endpoint. To get the list of SHA256s for the Job's Resources, see The Resources array from Get a Job Summary.

  • twinwave-get-engines

    List Available Engines.

  • twinwave-get-job-normalized-forensics

    Get a Job's Normalized Forensics.

  • twinwave-get-job-summary

    Get Job Summary.

  • twinwave-get-task-normalized-forensics

    Get a Task's Normalized Forensics.

  • twinwave-get-task-raw-forensics

    Get a Task's Raw Forensics.

  • twinwave-get-temp-artifact-url

    Get a Temporary Artifact URL.

  • twinwave-resubmit-job

    Resubmit a Job.

  • twinwave-search-across-jobs-and-resources

    Search Across Jobs and Resources.

  • twinwave-submit-file

    Submit File for Scanning.

  • twinwave-submit-url

    Submit New URL for Scanning.

import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401

""" IMPORTS """

import requests
import urllib3

# Disable insecure warnings
urllib3.disable_warnings()

""" CONSTANTS """
DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
API_HOST = "https://api.twinwave.io"
API_VERSION = "v1"
EXPIRE_SECONDS = 86400


class AuthenticationException(Exception):
    pass


class Client(BaseClient):
    """
    Client to connect to the API
    """

    def __init__(self, api_token, verify, proxy, host=API_HOST, version=API_VERSION):
        self.host = f"{host}/{version}"
        self.api_token = api_token
        self._verify = verify
        self._proxy = proxy

    def get_token(self):
        auth_url = f"{self.host}/accesstoken"
        resp = requests.get(auth_url, verify=self._verify, proxies=self._proxy)
        if resp.ok:
            return resp.json()
        else:
            raise AuthenticationException("Error getting access token, Please check the username and password")

    def get_header(self):
        return {"X-API-KEY": self.api_token}

    def get_recent_jobs(self, num_jobs=10, username=None, source=None, state=None):
        url = f"{self.host}/jobs/recent"
        params = {}
        params["count"] = num_jobs
        if username:
            params["username"] = username
        if source:
            params["source"] = source
        if state:
            params["state"] = state
        resp = requests.get(url, params=params, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()

    def get_engines(self):
        url = f"{self.host}/engines"
        resp = requests.get(url, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()

    def get_job(self, job_id):
        url = f"{self.host}/jobs/{job_id}"
        resp = requests.get(url, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()

    def get_task_normalized_forensics(self, job_id, task_id):
        url = f"{self.host}/jobs/{job_id}/tasks/{task_id}/forensics"
        resp = requests.get(url, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()

    def get_job_normalized_forensics(self, job_id):
        url = f"{self.host}/jobs/{job_id}/forensics"
        resp = requests.get(url, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()

    def get_task_raw_forensics(self, job_id, task_id):
        url = f"{self.host}/jobs/{job_id}/tasks/{task_id}/rawforensics"
        resp = requests.get(url, headers=self.get_header(), verify=self._verify, proxies=self._proxy)

        # do not raise an exception for 404
        if resp.status_code == 404:
            return resp.json()
        resp.raise_for_status()
        return resp.json()

    def submit_url(self, scan_url, engine_list=[], parameters=None, priority=None, profile=None):
        url = f"{self.host}/jobs/urls"
        req = {"url": scan_url, "engines": engine_list, "parameters": parameters}
        if priority:
            req["priority"] = priority
        if profile:
            req["profile"] = profile

        resp = requests.post(url, json=req, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()

    def submit_file(self, file_name, file_obj, engine_list=[], priority=None, profile=None):
        url = f"{self.host}/jobs/files"
        payload = {}
        file_dict = {"filedata": file_obj}
        payload["engines"] = (None, json.dumps(engine_list))
        payload["filename"] = (None, file_name)
        payload["priority"] = priority
        payload["profile"] = profile

        resp = requests.post(
            url, data=payload, files=file_dict, headers=self.get_header(), verify=self._verify, proxies=self._proxy
        )
        resp.raise_for_status()
        return resp.json()

    def resubmit_job(self, job_id):
        url = f"{self.host}/jobs/{job_id}/reanalyze"
        resp = requests.get(url, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()

    def download_submitted_resources(self, job_id, sha256):
        url = f"{self.host}/jobs/{job_id}/resources/{sha256}"
        resp = requests.get(url, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp

    def get_temp_artifact_url(self, path):
        url = f"{self.host}/jobs/artifact/url?path={path}"
        resp = requests.get(url, headers=self.get_header(), verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()

    def search_across_jobs_and_resources(self, term, field, count, shared_only, submitted_by, timeframe, page, type):
        query_params = {}
        if term:
            query_params["term"] = term
        if field:
            query_params["field"] = field
        if count:
            query_params["count"] = count
        if shared_only:
            query_params["shared_only"] = shared_only
        if submitted_by:
            query_params["submitted_by"] = submitted_by
        if timeframe:
            query_params["timeframe"] = timeframe
        if page:
            query_params["page"] = page
        if type:
            query_params["type"] = type
        url = f"{self.host}/jobs/search"
        resp = requests.get(url, headers=self.get_header(), params=query_params, verify=self._verify, proxies=self._proxy)
        resp.raise_for_status()
        return resp.json()


def test_module(client):
    """
    Returning 'ok' indicates that the integration works like it is supposed to. Connection to the service is successful.

    Args:
        client: HelloWorld client

    Returns:
        'ok' if test passed, anything else will fail the test.
    """
    try:
        header = get_engines(client)
        if header:
            return "ok"
        else:
            return_error("Authentication failed! Please check the username and password before continuing.")
    except Exception:
        return_error("Authentication failed! Please check the username and password before continuing.")


def get_engines(client):
    """
    List of engines
    """
    result = client.get_engines()
    # readable output will be in markdown format
    readable_output = "## List of Engines\n"
    readable_output += tableToMarkdown("Twinwave Engines", result, headers=["Name", "SupportedTypes", "DefaultEnabled"])
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Twinwave.Engines",
        outputs_key_field="Name",
        outputs=result,
        raw_response=result,  # raw response - the original response
    )


def validate_priority(priority):
    int_priority = 0
    validate_string = "Validation failed. Please make sure the priority is a numeric value within 1-225"
    if priority:
        if not priority.isnumeric():
            return_error(validate_string)
        else:
            try:
                int_priority = int(priority)
            except ValueError:
                return_error(validate_string)

            if int_priority <= 0 or int_priority >= 256:
                return_error(validate_string)
    else:
        return_error(validate_string)

    return int_priority


def submit_url(client, args):
    """
    Submit the URL
    """
    url = args.get("url")
    engines = argToList(args.get("engines", "[]"))
    parameters = args.get("parameters")
    priority = args.get("priority", 10)
    profile = args.get("profile")

    # validation for priority
    priority = validate_priority(priority)

    # validate the url
    regex_matches = re.match(urlRegex, url)
    if regex_matches:
        # passing the validated url into the search
        result = client.submit_url(
            scan_url=regex_matches.group(0), engine_list=engines, parameters=parameters, priority=priority, profile=profile
        )

        # readable output will be in markdown format
        readable_output = "## Submitted URL\n"
        readable_output += tableToMarkdown("Twinwave Submissions", result, headers=["JobID"])
        return CommandResults(
            readable_output=readable_output,
            outputs_prefix="Twinwave.Submissions",
            outputs_key_field="JobID",
            outputs=result,
            raw_response=result,  # raw response - the original response
        )

    return_error("Validation Failed. Please check the format of the submitted URL")
    return None


def submit_file(client, args):
    """
    Submit the File
    """
    file_entry_id = args.get("entry_id")
    file_path = demisto.getFilePath(file_entry_id)["path"]
    file_name = demisto.getFilePath(file_entry_id)["name"]
    engines = argToList(args.get("engines", "[]"))
    priority = args.get("priority", 10)
    profile = args.get("profile")

    # validation for priority
    priority = validate_priority(priority)

    with open(file_path, "rb") as file:
        result = client.submit_file(
            file_name=file_name, file_obj=file.read(), engine_list=engines, priority=priority, profile=profile
        )

    readable_output = "## Submitted File\n"
    readable_output += tableToMarkdown("Twinwave Submissions", result, headers=["JobID"])

    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Twinwave.Submissions",
        outputs_key_field="JobID",
        outputs=result,
        raw_response=result,  # raw response - the original response
    )


def resubmit_job(client, args):
    """
    Re submit a job
    """
    job_id = args.get("job_id")

    result = client.resubmit_job(job_id=job_id)
    # readable output will be in markdown format
    readable_output = "## Resubmitted Job"
    readable_output += tableToMarkdown("Twinwave Submissions", result, headers=["JobID"])
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Twinwave.Submissions",
        outputs_key_field="JobID",
        outputs=result,
        raw_response=result,  # raw response - the original response
    )


def list_recent_jobs(client, args):
    incidents = []
    last_run = demisto.getLastRun()
    jobs = []

    count = args.get("first_fetch", 10)
    state = args.get("state")
    username = args.get("username")
    source = args.get("source")
    if source == "all":
        source = None

    # initial fetch
    if not last_run:
        jobs = client.get_recent_jobs(num_jobs=count, state=state, username=username, source=source)
    else:
        max_fetch = min(int(args.get("max_fetch", 50)), 200)
        # logic to fetch the incidents from the last run onwards
        # retrieving 50 incidents at a time assuming
        # there will not be 50 events submitted concurrently within a 1 min period
        retrieved_jobs = client.get_recent_jobs(num_jobs=max_fetch, state=state, username=username, source=source)
        for job in retrieved_jobs:
            # comparing the time to see if the last fetch time is less than or equal
            # to the incidents that are being fetched
            # if last fetch time is lower, will fetch the incidents
            if int(date_to_timestamp(job.get("CreatedAt"), date_format=DATE_FORMAT)) <= int(last_run.get("last_created_at")):
                break
            jobs.append(job)

    for job in jobs:
        incident = {"name": job.get("ID"), "occurred": job["CreatedAt"], "rawJSON": json.dumps(job)}
        incidents.append(incident)
    # set the last run
    if jobs:
        # access the first of the array and set the start date and time to last run
        demisto.setLastRun({"last_created_at": date_to_timestamp(jobs[0].get("CreatedAt"), date_format=DATE_FORMAT)})

    # return the incidents
    return incidents


def search_across_jobs_and_resources(client, args):
    """
    Search across jobs and resources
    """
    term = args.get("term")
    field = args.get("field")
    type = args.get("type")
    count = args.get("count")
    shared_only = args.get("shared_only")
    submitted_by = args.get("submitted_by")
    timeframe = args.get("timeframe")
    page = args.get("page")
    result = client.search_across_jobs_and_resources(
        term=term,
        field=field,
        type=type,
        count=count,
        shared_only=shared_only,
        submitted_by=submitted_by,
        timeframe=timeframe,
        page=page,
    )
    readable_output = "## Search Across Jobs and Resources \n"
    readable_output += tableToMarkdown("Jobs and Resources", result.get("Jobs"), headers=[])
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Twinwave.JobsAndResources",
        outputs_key_field="Jobs.ID",
        outputs=result,
        raw_response=result,  # raw response - the original response
    )


def get_job_summary(client, args):
    """
    Job Summary
    """
    job_id = args.get("job_id")
    result = client.get_job(job_id=job_id)
    command_results = []
    # Setting the DbotScore
    twinwave_score = round(float(result.get("Score")) * 100, 2)
    if twinwave_score >= 70:
        score = Common.DBotScore.BAD
    elif twinwave_score >= 30:
        score = Common.DBotScore.SUSPICIOUS
    else:
        score = Common.DBotScore.GOOD

    submission = result.get("Submission")
    resources = result.get("Resources")
    tasks = result.get("Tasks")
    # created_at = result.get('CreatedAt')

    readable_output = "## Job Summary\n"

    # check if the job is a file
    if submission.get("SHA256"):
        size = None

        # if Twinwave scan is not completed, don't set the DBotScore
        if result.get("State") == "done":
            # extracting more information from the job
            if resources:
                for resource in resources:
                    file_metadata = resource.get("FileMetadata")
                    if file_metadata and file_metadata.get("SHA256") == submission.get("SHA256"):
                        size = file_metadata.get("Size")

            dbot_score = Common.DBotScore(
                indicator=submission.get("SHA256"), indicator_type=DBotScoreType.FILE, integration_name="Twinwave", score=score
            )

            file = Common.File(
                name=submission.get("Name"),
                md5=submission.get("MD5"),
                sha256=submission.get("SHA256"),
                size=size,
                dbot_score=dbot_score,
            )
            command_results.append(
                CommandResults(readable_output=tableToMarkdown("New File indicator was added", file.to_context()), indicator=file)
            )
        # Adding the SHA256 to the Result
        result["SHA256"] = submission.get("SHA256")
        result["DisplayScore"] = twinwave_score
        readable_output += tableToMarkdown(
            "Twinwave Job Summary", result, headers=["ID", "SHA256", "State", "DisplayScore", "ResourceCount", "CreatedAt"]
        )
    # check if the job is a URL
    else:
        # if Twinwave scan is not completed, don't set the DBotScore
        if result.get("State") == "done":
            detection_engines = set()
            positive_engines = set()

            for task in tasks:
                detection_engines.add(task.get("Engine"))
                if task.get("Results") and task.get("Results").get("Score") > 0:
                    positive_engines.add(task.get("Engine"))

            dbot_score = Common.DBotScore(
                indicator=submission.get("Name"), indicator_type=DBotScoreType.URL, integration_name="Twinwave", score=score
            )

            url = Common.URL(url=submission.get("Name"), detection_engines=list(detection_engines), dbot_score=dbot_score)

            command_results.append(
                CommandResults(readable_output=tableToMarkdown("New URL indicator was added", url.to_context()), indicator=url)
            )
        # Adding the URL to the Result
        result["URL"] = submission.get("Name")
        # Adding the DisplayScore to the result
        result["DisplayScore"] = twinwave_score
        readable_output += tableToMarkdown(
            "Twinwave Job Summary", result, headers=["ID", "URL", "State", "DisplayScore", "ResourceCount", "CreatedAt"]
        )
    command_results.insert(
        0,
        CommandResults(
            readable_output=readable_output,
            outputs_prefix="Twinwave.JobSummary",
            outputs_key_field="ID",
            outputs=result,
            raw_response=result,  # raw response - the original response
        ),
    )
    return command_results


def get_job_normalized_forensics(client, args):
    """
    Job Normalized Forensics
    """
    job_id = args.get("job_id")
    result = client.get_job_normalized_forensics(job_id=job_id)
    # adding the job id to the results
    if result and isinstance(result, dict):
        result["JobID"] = job_id
    # readable output will be in markdown format
    readable_output = "## Normalized Forensics\n"
    readable_output += tableToMarkdown(
        "Twinwave Job Normalized Forensics",
        result,
        headers=["JobID", "Version", "Engine", "DisplayScore", "Verdict", "StartTime", "EndTime"],
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Twinwave.JobNormalizedForensics",
        outputs_key_field="JobID",
        outputs=result,
        raw_response=result,  # raw response - the original response
    )


def get_task_normalized_forensics(client, args):
    """
    Task Normalized Forensics
    """
    job_id = args.get("job_id")
    task_id = args.get("task_id")
    result = client.get_task_normalized_forensics(job_id=job_id, task_id=task_id)
    # adding the job id and task id to the results
    if result and isinstance(result, dict):
        result["TaskID"] = task_id
        result["JobID"] = job_id
    # readable output will be in markdown format
    readable_output = "## Normalized Forensics\n"
    readable_output += tableToMarkdown(
        "Twinwave Task Normalized Forensics",
        result,
        headers=["JobID", "TaskID", "Version", "Engine", "DisplayScore", "Verdict", "StartTime", "EndTime"],
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Twinwave.TaskNormalizedForensics",
        outputs_key_field="TaskID",
        outputs=result,
        raw_response=result,  # raw response - the original response
    )


def get_task_raw_forensics(client, args):
    """
    Task Raw Forensics
    """
    job_id = args.get("job_id")
    task_id = args.get("task_id")
    result = client.get_task_raw_forensics(job_id=job_id, task_id=task_id)
    # adding the job id and task id to the results
    if result and isinstance(result, dict):
        result["TaskID"] = task_id
        result["JobID"] = job_id
    # readable output will be in markdown format
    readable_output = "## Raw Forensics\n"
    readable_output += tableToMarkdown("Twinwave Task Raw Forensics", result, headers=[])
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Twinwave.TaskRawForensics",
        outputs_key_field="TaskID",
        outputs=result,
        raw_response=result,  # raw response - the original response
    )


def download_submitted_resource(client, args):
    """
    Download submitted resource
    """
    job_id = args.get("job_id")
    sha256 = args.get("sha256")
    result = client.download_submitted_resources(job_id=job_id, sha256=sha256)
    return fileResult("resources.zip", data=result.content)


def get_temp_artifact_url(client, args):
    """
    Get Temp Artifact URL
    """
    path = args.get("path")
    result = client.get_temp_artifact_url(path=path)
    # readable output will be in markdown format
    readable_output = "## Get Temp Artifact URL \n"
    readable_output += tableToMarkdown("Temp Artifact URL", result, headers=[])
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Twinwave.TempArtifactURL",
        outputs_key_field="URL",
        outputs=result,
        raw_response=result,  # raw response - the original response
    )


def main():
    """
    PARSE AND VALIDATE INTEGRATION PARAMS
    """
    params = demisto.params()

    api_token = params.get("api-token")
    verify_certificate = not params.get("insecure", False)
    proxy = handle_proxy()

    LOG(f"Command being called is {demisto.command()}")
    try:
        client = Client(api_token=api_token, verify=verify_certificate, proxy=proxy)

        if demisto.command() == "test-module":
            # This is the call made when pressing the integration Test button.
            result = test_module(client)
            return_results(result)

        elif demisto.command() == "fetch-incidents":
            demisto.incidents(list_recent_jobs(client, params))

        elif demisto.command() == "twinwave-get-engines":
            return_results(get_engines(client))

        elif demisto.command() == "twinwave-submit-url":
            return_results(submit_url(client, demisto.args()))

        elif demisto.command() == "twinwave-submit-file":
            return_results(submit_file(client, demisto.args()))

        elif demisto.command() == "twinwave-resubmit-job":
            return_results(resubmit_job(client, demisto.args()))

        elif demisto.command() == "twinwave-list-recent-jobs":
            return_results(list_recent_jobs(client, demisto.args()))

        elif demisto.command() == "twinwave-get-job-summary":
            return_results(get_job_summary(client, demisto.args()))

        elif demisto.command() == "twinwave-get-job-normalized-forensics":
            return_results(get_job_normalized_forensics(client, demisto.args()))

        elif demisto.command() == "twinwave-get-task-normalized-forensics":
            return_results(get_task_normalized_forensics(client, demisto.args()))

        elif demisto.command() == "twinwave-get-task-raw-forensics":
            return_results(get_task_raw_forensics(client, demisto.args()))

        elif demisto.command() == "twinwave-download-submitted-resource":
            return_results(download_submitted_resource(client, demisto.args()))

        elif demisto.command() == "twinwave-get-temp-artifact-url":
            return_results(get_temp_artifact_url(client, demisto.args()))

        elif demisto.command() == "twinwave-search-across-jobs-and-resources":
            return_results(search_across_jobs_and_resources(client, demisto.args()))

            # Log exceptions
    except Exception as e:
        return_error(f"Failed to execute {demisto.command()} command. Error: {e!s}")


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