BechtleDarkWebScan

Notifies you if login credentials to your company are being sold on the dark web.

Identity and Access Management · Bechtle Dark Web Scan

Details

IDBechtleDarkWebScan
ProviderBechtle
CategoryIdentity and Access Management
From Version6.10.0
Docker Imagedemisto/python3:3.12.13.12042988

README

The dark web scan integration notifies you if login credentials to your company are being sold on the dark web.

Configure BechtleDarkWebScan in Cortex

Parameter Description Required
API Key Your darkwebscan.app API Key True
Additional request headers Additional request headers False
Fetch incidents Whether to fetch incidents False
Incidents Fetch Interval The interval incidents should be fetched False
First fetch time The first fetch time False
Incident type The incident type False
Maximum incidents per fetch Maxmimum amount of incidents per fetch False
Trust any certificate (not secure) Whether to trust any certificate False
Use system proxy settings Whether to 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.

darkwebscan-getcompanies


Returns a list of all companies associated with your API key.

Base Command

darkwebscan-getcompanies

Input

There are no input arguments for this command.

Context Output

Path Type Description
BechtleDarkWebScan.Companies String All companies that you have access to.

darkwebscan-getleaks


Returns leaked credentials for a given company_id.

Base Command

darkwebscan-getleaks

Input

Argument Name Description Required
company_id ID of the company to retrieve leaked credentials for. Required
only_new Only return leaks not previously seen by this integration instance. Possible values are: true, false. Default is false. Optional

Context Output

Path Type Description
BechtleDarkWebScan.LeakedCredentials String Leaked credentials for a given company_id.

darkwebscan-getosint


Returns OSINT information about the associated domain of a given company_id.

Base Command

darkwebscan-getosint

Input

Argument Name Description Required
company_id ID of the company to retrieve OSINT information for. Required

Context Output

Path Type Description
BechtleDarkWebScan.OSINT String OSINT information (email addresses, subdomains, …) for the associated domain of a given company_id.

darkwebscan-getemailsecurity


Returns information about the email security for the associated domain of a given company_id.

Base Command

darkwebscan-getemailsecurity

Input

Argument Name Description Required
company_id ID of the company to retrieve email security information for. Required

Context Output

Path Type Description
BechtleDarkWebScan.EmailSecurity String Email security (SPF, DMARC, DANE, …) for the associated domain of a given company_id.

darkwebscan-getwaf


Returns Web Application Firewall Status information about the associated domain of a given company_id.

Base Command

darkwebscan-getwaf

Input

Argument Name Description Required
company_id ID of the company to retrieve Web Application Firewall Status information for. Required

Context Output

Path Type Description
BechtleDarkWebScan.WAF String Web Application Firewall status and product name (if applicable)

darkwebscan-resetcontext


Resets integration context for the DarkWebScan. May result in many alerts reappearing.

Base Command

darkwebscan-resetcontext

Input

There are no input arguments for this command.

Context Output

Path Type Description
BechtleDarkWebScan.ResetContext String Returns text whether the reset was successful or not.

Configuration parameters

  • apikey — API Key (required)
  • addreqheaders — Additional request headers
  • isFetch — Fetch incidents
  • incidentFetchInterval — Incidents Fetch Interval
  • first_fetch — First fetch time
  • incidentType — Incident type
  • max_fetch — Maximum incidents per fetch
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (6)

  • darkwebscan-getcompanies

    Returns a list of all companies associated with your API key.

  • darkwebscan-getemailsecurity

    Returns information about the email security for the associated domain of a given company_id.

  • darkwebscan-getleaks

    Returns leaked credentials for a given company_id.

  • darkwebscan-getosint

    Returns OSINT information about the associated domain of a given company_id.

  • darkwebscan-getwaf

    Returns Web Application Firewall Status information about the associated domain of a given company_id.

  • darkwebscan-resetcontext

    Resets integration context for the DarkWebScan. May result in many alerts reappearing.

import json
from typing import Any

import demistomock as demisto
import urllib3
from datetime import datetime, UTC
from CommonServerPython import *
from urllib3.exceptions import InsecureRequestWarning

urllib3.disable_warnings(InsecureRequestWarning)

""" CONSTANTS """

DEFAULT_BASE_URL = "https://api.darkwebscan.app/v1"
DEFAULT_PAGE_SIZE = 50

INTEGRATION_NAME = "BechtleDarkWebScan"
INTEGRATION_VERSION = "1.0.0"

VENDOR = "Bechtle"

SENSITIVE_FIELDS_FOR_REDACTION = {"password", "password_hash", "cvv", "card_number"}

CONTEXT_SEEN_LEAK_IDS_KEY = "seen_leak_ids"
CONTEXT_LAST_FETCH_KEY = "last_leak_fetch"
MAX_SEEN_IDS_TO_RETAIN = 5000
MAX_SEEN_IDS_PER_COMPANY = 1000


""" CLIENT CLASS """


class Client(BaseClient):
    """
    Client class to interact with the DarkWebScan API.
    All API-specific logic (auth, pagination, request shaping) lives here.
    Command functions should call this client rather than using requests directly.
    """

    def __init__(self, base_url: str, api_key: str, additional_request_headers: str, verify: bool, proxy: bool):
        headers = {
            "apiKey": f"{api_key}",
            "Content-Type": "application/json",
            "User-Agent": f"{INTEGRATION_NAME}-PANW-XSOAR/{INTEGRATION_VERSION}",
        }

        if additional_request_headers:
            for line in additional_request_headers.strip().splitlines():
                if ":" in line:
                    key, val = line.split(":", 1)
                    headers[key.strip()] = val.strip()

        super().__init__(base_url=base_url, verify=verify, proxy=proxy, headers=headers)

    def get_companies(self, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE) -> dict:
        """
        Retrieve the list of companies/assets currently being monitored.
        """
        params = {
            "page": page,
            "page_size": page_size,
        }

        response = self._http_request(method="GET", url_suffix="/user/companies", params=params, resp_type="response")

        if not response.text or response.status_code == 204:
            return {}

        try:
            return response.json()
        except ValueError:
            raise DemistoException(
                f"API endpoint returned invalid, non-JSON text data (Status {response.status_code}): {response.text}"
            )

    def get_leaks(
        self, company_id: int = 0, since: Optional[str] = None, page: int = 1, page_size: int = DEFAULT_PAGE_SIZE
    ) -> dict:
        """
        Retrieve leaked credentials for a given company_id.
        """
        try:
            params = {
                "companyId": company_id,
                "page": page,
                "amount": page_size,
            }
            return self._http_request(
                method="GET",
                url_suffix="/user/lookout/my-leaked-data",
                params=params,
            )
        except Exception as e:
            demisto.error(f"get_leaks: {e}")
            return {}

    def get_email_security(
        self,
        company_id: int = 0,
    ) -> dict:
        """
        Retrieve information about the email security of the domain from a given company_id.
        """
        params = {"companyId": company_id}
        return self._http_request(
            method="GET",
            url_suffix="/scan/email-security",
            params=params,
        )

    def get_osint(
        self,
        company_id: int = 0,
    ) -> dict:
        """
        Retrieve OSINT information about the associated domain of a given company_id.
        """
        params = {"companyId": company_id}
        return self._http_request(
            method="GET",
            url_suffix="/scan/osint",
            params=params,
        )

    def get_waf(
        self,
        company_id: int = 0,
    ) -> dict:
        """
        Retrieve Web Application Firewall Status information about the associated domain of a given company_id.
        """
        params = {"companyId": company_id}
        return self._http_request(
            method="GET",
            url_suffix="/scan/waf",
            params=params,
        )


""" HELPER FUNCTIONS """


def _redact_rows(rows: list[dict[str, Any]], redact: bool) -> list[dict[str, Any]]:
    """
    Mask sensitive fields (e.g. plaintext passwords) before they hit the
    War Room / context unless the integration is explicitly configured not to.
    """
    if not redact:
        return rows
    return [
        {k: ("***" if k in SENSITIVE_FIELDS_FOR_REDACTION and v not in (None, "", []) else v) for k, v in row.items()}
        for row in rows
    ]


def _should_redact_secrets(params: dict) -> bool:
    raw = params.get("redact_secrets")
    if raw is None:
        return True
    if isinstance(raw, bool):
        return raw
    return str(raw).strip().lower() in {"true", "1", "yes", "on"}


""" COMMAND FUNCTIONS """


def test_module(client: Client) -> str:
    """
    Called when clicking the 'Test' button in the integration configuration.
    Validates the API key / connectivity by making a lightweight call.
    """
    client.get_companies(page=1, page_size=1)
    return "ok"


def _color_tag(text: str, color: str) -> str:
    """
    Wrap text in XSOAR-flavored markdown color syntax.
    """
    if not color:
        return text
    return f"{{{{color:{color}}}}}({text})"


def _get_all_company_ids(client: Client) -> list[int]:
    """
    Fetch all monitored companies in a single call.
    """
    response = client.get_companies(page=1, page_size=DEFAULT_PAGE_SIZE)
    if isinstance(response, list):
        companies = response
    else:
        companies = response.get("companies", response.get("results", []))
    return [c["company_id"] for c in companies if c.get("company_id") is not None]


def _to_iso8601(date_str: Optional[str]) -> str:
    """
    Normalize a date/datetime string to full ISO 8601 (YYYY-MM-DDTHH:MM:SSZ),
    as required by the 'occurred' field in incident dicts.
    """
    if not date_str:
        return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")

    dt = arg_to_datetime(date_str)
    if dt is None:
        return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")

    return dt.strftime("%Y-%m-%dT%H:%M:%SZ")


def get_company_id_arg(args: dict) -> int:
    """
    Extracts, validates, and explicitly casts 'company_id' from command arguments.
    Raises a ValueError if missing or invalid.
    """
    company_id_raw = args.get("company_id")

    if company_id_raw is None:
        raise ValueError("Missing required argument: 'company_id'")

    company_id = arg_to_number(company_id_raw)

    if company_id is None:
        raise ValueError(f"Invalid integer value provided for 'company_id': {company_id_raw}")

    return int(company_id)


def darkwebscan_getcompanies_command(client: Client, args: dict) -> CommandResults:
    """
    darkwebscan-getcompanies
    Lists the companies/assets currently monitored by BechtleDarkWebScan.
    """
    page = min(50000, max(0, arg_to_number(args.get("page")) or 0))
    page_size = min(50000, max(0, arg_to_number(args.get("page_size")) or DEFAULT_PAGE_SIZE))

    companies = client.get_companies(page=page, page_size=page_size)

    if not isinstance(companies, list):
        companies_list = [companies] if companies else []
    else:
        companies_list = companies

    # Map the exact keys returned by the API to the markdown table headers
    readable_output = tableToMarkdown(
        name="DarkWebScan Companies",
        t=companies_list,
        headers=["company_id", "printableName", "fqdn", "scanCreditsAmount"],
        removeNull=True,
    )

    return CommandResults(
        outputs_prefix="BechtleDarkWebScan.Companies",
        outputs_key_field="id",
        outputs=companies_list,
        readable_output=readable_output,
        raw_response=companies_list,
    )


def darkwebscan_getleaks_command(client: Client, args: dict) -> CommandResults:
    """
    darkwebscan-getleaks
    Retrieves leaked-credential records, optionally filtered by company and/or
    a start date, with optional redaction of sensitive fields.
    """
    only_new = argToBoolean(args.get("only_new", "false"))
    company_id = get_company_id_arg(args)
    since = args.get("since")
    page = arg_to_number(args.get("page")) or 1
    page_size = arg_to_number(args.get("page_size")) or DEFAULT_PAGE_SIZE
    redact = argToBoolean(args.get("redact_secrets", "true"))

    if args.get("company_id") is not None and company_id is None:
        raise ValueError(f"Invalid 'company_id' value: {args.get('company_id')}. Must be an integer.")

    integration_context = get_integration_context()
    seen_ids = set(integration_context.get(CONTEXT_SEEN_LEAK_IDS_KEY, []))
    last_fetch = integration_context.get(CONTEXT_LAST_FETCH_KEY)

    response = client.get_leaks(company_id=company_id, since=since, page=page, page_size=page_size)
    leaks = response.get("searchResults", None)

    if only_new:
        new_leaks = [leak for leak in leaks if leak.get("id") not in seen_ids]
    else:
        new_leaks = leaks

    # Update context: remember these IDs
    if new_leaks:
        newest_timestamp = max(
            (leak.get("date") for leak in new_leaks if leak.get("date")),
            default=last_fetch,
        )
        seen_ids.update(leak.get("id") for leak in new_leaks if leak.get("id"))
        if len(seen_ids) > MAX_SEEN_IDS_TO_RETAIN:
            seen_ids = set(list(seen_ids)[-MAX_SEEN_IDS_TO_RETAIN:])

        set_integration_context({CONTEXT_SEEN_LEAK_IDS_KEY: list(seen_ids), CONTEXT_LAST_FETCH_KEY: newest_timestamp})

    new_leaks = _redact_rows(new_leaks, redact)

    readable_output = tableToMarkdown(
        name="DarkWebScan Leaked Credentials (only new)" if only_new else "DarkWebScan Leaked Credentials",
        t=new_leaks,
        headers=["id", "domain", "links", "stealer", "source", "username", "price", "size", "date"],
        removeNull=True,
    )

    return CommandResults(
        outputs_prefix="BechtleDarkWebScan.LeakedCredentials",
        outputs_key_field="id",
        outputs=new_leaks,
        readable_output=readable_output,
        raw_response=response,
    )


def format_subdomains_and_emails(api_response: dict) -> str:
    """
    Transforms the API JSON response into two human-readable Markdown tables.
    """
    markdown_outputs = []

    subdomains_list = api_response.get("subdomains", [])
    if subdomains_list:
        subdomains_table = tableToMarkdown(
            name="Monitored Subdomains", t=subdomains_list, headers=["subdomain", "description"], headerTransform=pascalToSpace
        )
        markdown_outputs.append(subdomains_table)

    emails_list = api_response.get("emailAddresses", [])
    if emails_list:
        emails_table = tableToMarkdown(
            name="Monitored Email Addresses", t=emails_list, headers=["email"], headerTransform=string_to_table_header
        )
        markdown_outputs.append(emails_table)

    if markdown_outputs:
        return "\n\n".join(markdown_outputs)

    return "No subdomains or email addresses found."


def darkwebscan_getosint_command(client: Client, args: dict) -> CommandResults:
    """
    darkwebscan-getosint
    Returns OSINT information about the associated domain of a given company_id.
    """
    company_id = get_company_id_arg(args)
    response = client.get_osint(company_id=company_id)

    return CommandResults(
        outputs_prefix="BechtleDarkWebScan.OSINT",
        outputs_key_field="domain",
        outputs=response,
        readable_output=format_subdomains_and_emails(response),
        raw_response=response,
    )


def darkwebscan_getemailsecurity_command(client: Client, args: dict) -> CommandResults:
    """
    darkwebscan-getemailsecurity
    Returns information about the email security for the associated domain of a given company_id.
    """
    company_id = get_company_id_arg(args)
    response = client.get_email_security(company_id=company_id)

    spf = response.get("spf", {})
    dmarc = response.get("dmarc", {})
    dane = response.get("dane", {})

    output = {
        "SPF": {"Record": spf.get("spfRecord"), "Info": spf.get("warning"), "Summary": spf.get("summary")},
        "DMARC": {"Record": dmarc.get("dmarcRecord"), "Info": dmarc.get("warning"), "Summary": dmarc.get("summary")},
        "DANE": {"EmailHosts": dane.get("emailHosts"), "Info": dane.get("warning"), "Summary": dane.get("summary")},
    }

    summary_rows = [
        {"Check": "SPF", "Status": _color_tag(spf.get("summary"), spf.get("warningColor"))},
        {"Check": "DMARC", "Status": _color_tag(dmarc.get("summary"), dmarc.get("warningColor"))},
        {"Check": "DANE", "Status": _color_tag(dane.get("summary"), dane.get("warningColor"))},
    ]

    # Map the exact keys returned by the API to the markdown table headers
    readable_output = tableToMarkdown(
        name="DarkWebScan Email Security",
        t=summary_rows,
        headers=["Check", "Status"],
        removeNull=True,
    )

    return CommandResults(
        outputs_prefix="BechtleDarkWebScan.EmailSecurity",
        outputs_key_field="SPF",
        outputs=output,
        readable_output=readable_output,
        raw_response=response,
    )


def darkwebscan_getwaf_command(client: Client, args: dict) -> CommandResults:
    """
    darkwebscan-getosint
    Returns OSINT information about the associated domain of a given company_id.
    """
    company_id = get_company_id_arg(args)
    response = client.get_waf(company_id=company_id)

    summary = {
        "product": response.get("product"),
        "summary": response.get("summary"),
        "warning": _color_tag(response.get("warning") or "", response.get("warningColor") or ""),
    }

    # Map the exact keys returned by the API to the markdown table headers
    readable_output = tableToMarkdown(
        name="DarkWebScan Web Application Firewall Status",
        t=summary,
        headers=["product", "summary", "warning"],
        removeNull=True,
    )

    return CommandResults(
        outputs_prefix="BechtleDarkWebScan.WAF",
        outputs_key_field="summary",
        outputs=response,
        readable_output=readable_output,
        raw_response=response,
    )


def fetch_incidents(client: Client, last_run: dict, first_fetch: str, max_fetch: int) -> tuple[dict, list]:
    parsed_dt = arg_to_datetime(first_fetch)
    default_since = parsed_dt.strftime("%Y-%m-%dT%H:%M:%SZ") if parsed_dt else ""
    companies_state = last_run.get("companies", {})

    company_ids = _get_all_company_ids(client)

    incidents: list[dict] = []
    next_companies_state = {}

    for cid in company_ids:
        demisto.debug(f"cid: {cid}")
        cid_key = str(cid)
        state = companies_state.get(cid_key, {})
        since = state.get("last_fetch", default_since)
        seen_ids = set(state.get("seen_ids", []))

        demisto.debug(f"Getting leaks for company ID {cid}")

        response = client.get_leaks(company_id=cid, since=since, page=1, page_size=max_fetch)
        leaks = response.get("searchResults", [])

        newest_timestamp = since
        # demisto.debug("Iterating leaks...")
        for leak in leaks:
            leak_id = leak.get("id")
            if leak_id in seen_ids:
                demisto.debug("Already seen a leak with this ID. Ignoring...")
                continue
            if len(incidents) >= max_fetch:
                demisto.debug("Reached max_fetch. Skipping...")
                break

            alert_json = {
                "id": leak.get("id", None),
                "date": leak.get("date", "Unknown"),
                "domain": leak.get("domain", "Unknown"),
                "links": leak.get("links", "Unknown"),
                "username": leak.get("username", "Username not included with initial offer"),
                "price": leak.get("price", ""),
                "size": leak.get("size", None),
                "source": leak.get("source", None),
                "stealer": leak.get("stealer", None),
                "dataSource": "BechtleDarkWebScan",
            }

            occurred = _to_iso8601(leak.get("date") or None)
            demisto.debug("Preparing leak info...")
            incidents.append(
                {
                    "name": (
                        f"DarkWebScan Leak: {leak.get('links', 'unknown')} "
                        f"({leak.get('source', leak.get('stealer', 'unknown source'))})"
                    ),
                    "type": "BechtleDarkWebScan Incident",
                    "details": (
                        f"New Dark Web Leak: {leak.get('links', 'unknown URL')}, "
                        f"Username: {leak.get('username', 'Username not included with initial offer')}, "
                        f"Price: {leak.get('price', 'Unknown')}, "
                        f"Source: {leak.get('source', leak.get('stealer', 'unknown') + ' Stealer')}"
                    ),
                    "occurred": occurred,
                    "severity": 2,
                    "rawJSON": json.dumps(alert_json),
                }
            )
            demisto.debug("Adding this leak's id to the known leak ids...")
            seen_ids.add(leak_id)
            if occurred > newest_timestamp:
                newest_timestamp = occurred

        if len(seen_ids) > MAX_SEEN_IDS_PER_COMPANY:
            seen_ids = set(list(seen_ids)[-MAX_SEEN_IDS_PER_COMPANY:])

        demisto.debug("Setting next_companies_state...")
        next_companies_state[cid_key] = {"last_fetch": newest_timestamp, "seen_ids": list(seen_ids)}

    return {"companies": next_companies_state}, incidents


def darkwebscan_resetcontext_command() -> CommandResults:
    set_integration_context({})
    return CommandResults(readable_output="Integration context cleared.")


""" MAIN FUNCTION """


def main() -> None:
    params = demisto.params()
    args = demisto.args()
    command = demisto.command()

    api_key = params.get("apikey", {}).get("password") if isinstance(params.get("apikey"), dict) else params.get("apikey")
    additional_request_headers = params.get("addreqheaders")
    base_url = params.get("url", DEFAULT_BASE_URL).rstrip("/")
    verify_certificate = not params.get("insecure", False)
    proxy = params.get("proxy", False)

    demisto.debug(f"Command being called is {command}")

    try:
        client = Client(
            base_url=base_url,
            api_key=api_key,
            additional_request_headers=additional_request_headers,
            verify=verify_certificate,
            proxy=proxy,
        )

        if command in ("test-module", ""):
            return_results(test_module(client))

        elif command == "fetch-incidents":
            first_fetch = params.get("first_fetch", "6 months")
            max_fetch = arg_to_number(params.get("max_fetch")) or 50
            next_run, incidents = fetch_incidents(client, demisto.getLastRun(), first_fetch, max_fetch)
            demisto.setLastRun(next_run)
            demisto.incidents(incidents)

        elif command == "darkwebscan-getcompanies":
            return_results(darkwebscan_getcompanies_command(client, args))

        elif command == "darkwebscan-getleaks":
            return_results(darkwebscan_getleaks_command(client, args))

        elif command == "darkwebscan-getemailsecurity":
            return_results(darkwebscan_getemailsecurity_command(client, args))

        elif command == "darkwebscan-getosint":
            return_results(darkwebscan_getosint_command(client, args))

        elif command == "darkwebscan-getwaf":
            return_results(darkwebscan_getwaf_command(client, args))

        elif command == "darkwebscan-resetcontext":
            return_results(darkwebscan_resetcontext_command())

        else:
            raise NotImplementedError(f"Command '{command}' is not implemented.")

    except Exception as e:
        demisto.error(traceback.format_exc())
        return_error(f"Failed to execute {command} command.\nError:\n{str(e)}", error=e)


""" ENTRY POINT """

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