Looker

Use the Looker integration to query an explore, save queries as looks, run looks, and fetch look results as incidents.

Analytics & SIEM · Looker

Details

IDLooker
ProviderGoogle
CategoryAnalytics & SIEM
From Version5.0.0
Docker Imagedemisto/python3:3.12.13.10325753
Supported ModulesAgentix XSIAM

README

This integration was integrated and tested with Looker version 6.10.20.

Use Cases

  1. Ingest query results as incidents.
  2. Run a custom (inline) query as part of a playbook.
  3. Automatically create and save a query as a look.

Important Information

Make sure you read this information on how to obtain information required for configuring the integration.

Generate an API3 key for a Looker user:

  1. Log in to the Looker web interface with an account that is permitted to manage users.
  2. At the top of the page, click on the “Admin” drop down and select “Users”
  3. Select the user you would like to generate the API3 key for.
  4. Go to “API3 Keys” and select “Edit Keys”
  5. Click on “New API3 Key”

Get a Look ID:

Usages:

  • “Look name or ID to fetch incidents from” integration parameter.
  • Look ID command arguments.
  • Uniquely identify a Look (the name is not unique).

Option A: Looker Web Interface

  1. Click on a look.
  2. The number at the end of the URL is the ID of the look.

Option B: Cortex XSOAR commands

  1. Configure the Looker integration without fetching incidents, or filling in the parameter.
  2. Run the looker-search-queries or looker-search-looks command.
  3. The ID will be part of the results (among other look details).

Get model and view names from an explore’s URL:

  1. Navigate to the explore.
  2. The URL will be formatted like this: https://<looker server>/explore/<model>/<view>

Get a field’s SQL name (for command arguments):

  1. Navigate to the explore.
  2. Click a field.
  3. In the DATA tab, click SQL.

You will see the field name in the following format: object_name.field_name.

Configure Looker on Cortex XSOAR

  1. Navigate to Settings > Integrations > Servers & Services.
  2. Search for Looker.
  3. Click Add instance to create and configure a new integration instance.
    • Name: a textual name for the integration instance.
    • API URL and port (e.g., https://example.looker.com:19999)
    • Trust any certificate (not secure)
    • Use system proxy settings
    • API3 Client ID
    • API3 Client Secret
  4. Click Test to validate the URLs, token, and connection.

Commands

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

1. Run a saved look


Runs a saved look and returns the results in the specified format.

Base Command

looker-run-look

Input
Argument Name Description Required
id ID of the look. Can be found in the look’s URL, or by running the ‘looker-search-looks’ command. Optional
fields Fields to return. Optional
name Name of the look. Optional
limit Maximum number of looks to return (0 for looker-determined limit). Optional
result_format Format of the result. Required

 

Context Output
Path Type Description
LookerResults.LookID Number Look ID.
LookerResults.Results Unknown Look Results.

 

Command Example
looker-run-look name="Look 1" limit="2" result_format="json"
Context Example
"LookerResults": {
    "LookID": 3,
    "Results": [
        {
            "OrderItems_Id": 160086,
            "OrderItems_OrderId": 153797,
            "OrderItems_Status": "Complete",
            "OrderItems_CreatedDate": "2019-04-02",
            "OrderItems_SalePrice": 54,
            "Products_Brand": "Alternative",
            "Products_ItemName": "Alternative Women's Alice Drop Shoulder V-Neck",
            "Users_Name": "Chelsea Mccormick",
            "Users_Email": "example@gmail.com"
        },
        {
            "OrderItems_Id": 63757,
            "OrderItems_OrderId": 58557,
            "OrderItems_Status": "Cancelled",
            "OrderItems_CreatedDate": "2019-04-19",
            "OrderItems_SalePrice": 49.5,
            "Products_Brand": "Lucky Brand",
            "Products_ItemName": "Lucky Brand Women's Plus-Size Moroccan Medallion Tee",
            "Users_Name": "Darrell Nelson",
            "Users_Email": "example@aol.com"
        }
    ]
}
Human Readable Output

Results for look Look 1

LookerResults.Results.OrderItems_Id LookerResults.Results.OrderItems_OrderId LookerResults.Results.OrderItems_Status LookerResults.Results.OrderItems_CreatedDate LookerResults.Results.OrderItems_SalePrice LookerResults.Results.Products_Brand LookerResults.Results.Products_ItemName LookerResults.Results.Users_Name LookerResults.Results.Users_Email
160086 153797 Complete 2019-04-02 54 Alternative Alternative Women’s Alice Drop Shoulder V-Neck Chelsea Mccormick example.gmail.com
63757 58557 Cancelled 2019-04-19 49.5 Lucky Brand Lucky Brand Women’s Plus-Size Moroccan Medallion Tee Darrell Nelson example.gmail.com

This command has dynamic output keys.
To access them in the context, copy the key’s path from the column header in the results table.

2. Search for saved looks


Retrieves saved looks that match the search criteria.

Base Command

looker-search-looks

Input
Argument Name Description Required
name Match look name. Optional
space_id Filter results by a particular space. Optional
user_id Filter by dashboards created by a particular user. Optional
limit Maximum number of looks to return (0 for looker-determined limit). Optional

 

Context Output
Path Type Description
Looker.Look.ID Number Look ID.
Looker.Look.Name String Look name.
Looker.Look.SpaceID Number ID of the space that contains the look.
Looker.Look.SpaceName String Name of the space that contains the look.
Looker.Look.LastUpdated Date The time that the look was last updated.

 

Command Example
!looker-search-looks limit="2"
Context Example
"Looker": {
    "Look": {
        [
            {
                "ID": 3,
                "LastUpdated": "2019-04-10T16:11:43.249Z",
                "Name": "Look 1",
                "SpaceID": 6,
                "SpaceName": "Space 1"
            },
            {
                "ID": 4,
                "LastUpdated": "2019-04-16T11:41:57.482Z",
                "Name": "Look 2",
                "SpaceID": 7,
                "SpaceName": "Space 2"
            }
        ]
    }
}
Human Readable Output

Look search results

ID Name SpaceID SpaceName LastUpdated
3 Look 1 6 Space 1 2019-04-10T16:11:43.249Z
4 Look 2 7 Space 2 2019-04-16T11:41:57.482Z

 

3. Run an inline query


Runs a query by defining it in the command arguments, rather than a saved query in looker.

Base Command

looker-run-inline-query

Input
Argument Name Description Required
model Name of the model - can be found in the explore’s URL Required
view Name of the view or explore. Can be found in the explore’s URL. Required
fields List of fields to display. (Field name format: “object_name.field_name”). Optional
filters Filters for the query, passed as a comma-separated list with the format: “field name=filter value;…” (Field name format: “object_name.field_name”). Optional
pivots List of pivots. (Field name format: “object_name.field_name”). Optional
sorts Sorting for the query results. (Field name format: “object_name.field_name”). Optional
limit Maximum number of looks to return (0 for looker-determined limit). Optional
result_format Format of the result. Required

 

Context Output
Path Type Description
LookerResults.InlineQuery Unknown Inline query results.

 

Command Example
looker-run-inline-query model="thelook" view="order_items" fields="order_items.status, order_items.order_id, products.brand" filters="products.brand=Ray-Ban, Calvin Klein" limit="2" result_format="json"
Context Example
"LookerResults": {
        "InlineQuery": [
            {
                "OrderItems_OrderId": 5704,
                "OrderItems_Status": "Cancelled",
                "Products_Brand": "Ray-Ban"
            },
            {
                "OrderItems_OrderId": 1535,
                "OrderItems_Status": "Cancelled",
                "Products_Brand": "Ray-Ban"
            }
        ]
    }
Human Readable Output

Inline Query Results

LookerResults.InlineQuery.OrderItems_Status LookerResults.InlineQuery.OrderItems_OrderId LookerResults.InlineQuery.Products_Brand
Cancelled 5704 Ray-Ban
Cancelled 1535 Ray-Ban

 

4. Create a look


Creates a look from a query

Base Command

looker-create-look

Input
Argument Name Description Required
model Name of the model. Can be found in the explore’s URL. Required
view Name of the view or Explore. Can be found in the explore’s URL. Required
fields List of fields to display. (Field name format: “object_name.field_name”). Optional
filters Filters for the query, passed as a comma-separated list with the format: “field name=filter value;…” (Field name format: “object_name.field_name”). Optional
pivots List of pivots. (Field name format: “object_name.field_name”). Optional
sorts Sorting for the query results. (Field name format: “object_name.field_name”). Optional
look_title Title of the look. Required
look_description Description of the look. Optional
look_space_id ID of the space that will contain the look. Required

 

Context Output
Path Type Description
Looker.Look.ID Number Look ID.
Looker.Look.Name String Look name.
Looker.Look.SpaceID Number ID of the space that contains the look.
Looker.Look.SpaceName String Name of the space that contains the look.
Looker.Look.LastUpdated Date The time that the look was last updated.

 

This command has dynamic output keys.
To access them in the context, copy the key’s path from the column header in the results table.

Command Example
looker-run-inline-query model="thelook" view="order_items" fields="order_items.status, order_items.order_id, products.brand" filters="products.brand=Ray-Ban, Calvin Klein" limit="2" result_format="json" look_space_id=6 look_title="Look 3" look_description="This is my third saved look"
Context Example
"Looker": {
    "Look": {
        "ID": 7,
        "LastUpdated": "2019-04-10T16:11:43.249Z",
        "Name": "Look 3",
        "SpaceID": 6,
        "SpaceName": "Space 1"
    }
}
Human Readable Output

Look “Look 3” created successfully

ID Name SpaceID SpaceName LastUpdated
7 Look 3 6 Space 1 2019-04-10T16:11:43.249Z

Configuration parameters

  • url — API URL and port (e.g., https://example.looker.com:19999) (required)
  • client_id — API3 Client ID
  • client_secret — API3 Client Secret
  • credentials_api3_client — API3 Client ID
  • unsecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (4)

  • looker-create-look

    Creates a look from a query.

  • looker-run-inline-query

    Runs a query by defining it in the command arguments, rather than a saved query in looker.

  • looker-run-look

    Runs a saved look and returns the results in the specified format.

  • looker-search-looks

    Retrieves saved looks that match the search criteria.

import demistomock as demisto
from CommonServerPython import *
from CommonServerUserPython import *

""" IMPORTS """

import requests
import traceback
import json
import urllib3

# Disable insecure warnings
urllib3.disable_warnings()


""" CONSTANTS """
LAST_RUN_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
DEFAULT_RESULTS_LIMIT = 50
MAX_TIMEOUT_MINUTES = 5


""" GLOBALS/PARAMS """
SESSION_VALIDITY_THRESHOLD = timedelta(minutes=MAX_TIMEOUT_MINUTES)
CLIENT_ID = demisto.params().get("credentials_api3_client", {}).get("identifier") or demisto.params().get("client_id")
CLIENT_SECRET = demisto.params().get("credentials_api3_client", {}).get("password") or demisto.params().get("client_secret")
# Remove trailing slash to prevent wrong URL path to service
SERVER = (
    demisto.params()["url"][:-1]
    if (demisto.params()["url"] and demisto.params()["url"].endswith("/"))
    else demisto.params()["url"]
)
# Should we use SSL
USE_SSL = not demisto.params().get("unsecure", False)
# How many time before the first fetch to retrieve incidents
FETCH_TIME = demisto.params().get("fetch_time", "3 days")
# Service base URL
BASE_URL = SERVER + "/api/3.0"
# Request headers (preparation)
HEADERS: dict[str, str] = {}


""" HELPER FUNCTIONS """


def verify_url(url):
    # validate url parameter format, extract port
    try:
        server, port = url.rsplit(":", 1)
        assert 0 < int(port) < 65536

    except (ValueError, AssertionError):
        raise ValueError(
            "Incorrect URL format. Use the following format: https://example.looker.com:19999\n"
            "The default port for Looker API is 19999."
        )


def http_request(method, url_suffix, params=None, data=None, response_type="json"):
    # A wrapper for requests lib to send our requests and handle requests and responses better
    res = requests.request(method, BASE_URL + url_suffix, verify=USE_SSL, params=params, data=data, headers=HEADERS)

    # Handle error responses gracefully
    if res.status_code not in {200}:
        error_message = f"Error in API call to Looker [{res.status_code}] - {res.reason}"

        # Try to get detailed errors from looker json response
        if res.status_code in (400, 422):
            try:
                error_json = res.json()
                error_message += f"\n{error_json['message']}"

                if res.status_code == 422:
                    validation_error_message = ""
                    for validation_error in error_json["errors"]:
                        validation_error_message += f"\n{validation_error['field']} {validation_error['message']}"
                    error_message += validation_error_message
            except (KeyError, ValueError):
                pass

        raise requests.exceptions.HTTPError(error_message)

    # Return by expected type
    if response_type != "json":
        return res.content

    res_obj = res.json()

    # Handle non-http type error messages from looker
    if isinstance(res_obj, list) and len(res_obj) == 1 and isinstance(res_obj[0], dict) and "looker_error" in res_obj[0]:
        raise Exception(res_obj[0]["looker_error"])

    return res_obj


def get_new_token(client_id, client_secret):
    data = {"client_id": client_id, "client_secret": client_secret}

    try:
        response_json = http_request("POST", "/login", data=data)

        return {"token": response_json["access_token"], "expires": datetime.utcnow().timestamp() + response_json["expires_in"]}

    except requests.exceptions.HTTPError as ex:
        if "[404]" in str(ex):
            raise Exception(
                "Got 404 from server - check 'API3 Client ID' and 'API3 Client Secret' fields in the instance configuration."
            )
        raise


def get_session_token(client_id, client_secret):
    ic = demisto.getIntegrationContext()

    if (
        client_id not in ic
        or "expires" not in ic[client_id]
        or datetime.fromtimestamp(ic[client_id]["expires"]) < datetime.utcnow() + SESSION_VALIDITY_THRESHOLD
    ):
        ic[client_id] = get_new_token(client_id, client_secret)
        if demisto.command() != "test-module":
            demisto.setIntegrationContext(ic)

    return "token {}".format(ic[client_id]["token"])


def get_limit():
    try:
        limit = int(demisto.args().get("limit", DEFAULT_RESULTS_LIMIT))
        return None if limit == 0 else limit

    except ValueError:
        raise ValueError("limit must be a number")


def get_look_id_from_name(name):
    looks = search_looks({"title": name})
    if len(looks) < 1:
        raise Exception(f"No Look found with the name {name}.")
    if len(looks) > 1:
        raise Exception(
            f'There is more than one Look with the name "{name}".'
            f"\nUse look ID instead - It can be found in the Look's URL or by running looker-search-looks"
        )

    return looks[0]["ID"]


def full_path_headers(src_data, base_path):
    def to_full_path(k):
        return f"{base_path}.{k}"

    def full_path_headers_for_dict(src):
        if not isinstance(src, dict):
            return src

        return {to_full_path(k): v for k, v in src.items()}

    if not isinstance(src_data, list):
        src_data = [src_data]

    return [full_path_headers_for_dict(x) for x in src_data]


def parse_filters_arg(filters_arg_value):
    error_message = "'filters' argument format is invalid.\n"

    filters_list = argToList(filters_arg_value, ";")
    filters_list = [elem for elem in [x.strip() for x in filters_list] if elem]  # Remove empty elems
    if not filters_list:
        return None

    filters = {}
    filters_and_indices_list = zip(range(len(filters_list)), filters_list)  # Track element index for error messages
    for i, elem in filters_and_indices_list:
        try:
            k, v = elem.split("=", 1)
            k = k.strip()
            if not k:
                raise ValueError(f"{error_message}Filter in position {i+1}: field is empty.")
            v = v.strip()
            if not v:
                raise ValueError(f"{error_message}Filter in position {i+1} ({k}): value is empty.")
            filters[k] = v
        except ValueError:
            raise ValueError(f"{error_message}Filter in position {i+1} is missing '=' separator")

    return filters


def get_entries_for_search_results(contents, look_id=None, result_format="json", look_name=""):
    entries = []
    if result_format == "json":
        camelized = camelize(contents, delim="_")
        formatted_contents = replace_in_keys(camelized)
        if not isinstance(formatted_contents, list):
            formatted_contents = [formatted_contents]

        if look_id:
            context = {
                "LookerResults(val.LookID && val.LookID === obj.LookID)": {"LookID": int(look_id), "Results": formatted_contents}
            }
            hr_title = f'Results for look "{look_name}"' if look_name else f"Results for look #{look_id}"
            full_path_header_content = full_path_headers(formatted_contents, "LookerResults.Results")
        else:
            context = {"LookerResults.InlineQuery": formatted_contents}
            hr_title = "Inline Query Results"
            full_path_header_content = full_path_headers(formatted_contents, "LookerResults.InlineQuery")

        entries.append(
            {
                "Type": entryTypes["note"],
                "ContentsFormat": formats["json"],
                "Contents": contents,
                "ReadableContentsFormat": formats["markdown"],
                "HumanReadable": tableToMarkdown(hr_title, full_path_header_content, removeNull=True),
                "EntryContext": context,
            }
        )

        if contents:
            entries.append(  # type: ignore
                "This command has dynamic output keys.\n"  # type: ignore
                "To access them in the context, copy the key's path from the column header in the results table."
            )

    elif result_format == "csv":
        entries.append(
            fileResult("look_result.csv" if look_id else "inline_query_result.csv", contents, entryTypes["entryInfoFile"])
        )

    return entries


def get_query_args(demisto_args):
    str_args = ("model", "view")
    list_args = ("fields", "pivots", "sorts")
    args_dict = {k: argToList(demisto_args[k]) for k in list_args if k in demisto_args}  # Parse list-type arguments
    args_dict.update({k: demisto_args[k] for k in str_args})  # Add string-type arguments
    filters = parse_filters_arg(demisto_args.get("filters"))  # Handle special argument
    if filters:
        args_dict["filters"] = filters

    return args_dict


""" COMMANDS + REQUESTS FUNCTIONS """


def test_module():
    """
    Performs basic get request to check connectivity and authentication
    """
    http_request("GET", "/user")


def run_look_command():
    look_id = demisto.args().get("id")
    look_name = demisto.args().get("name")
    if not any((look_id, look_name)):
        raise Exception("Provide Look id or name.")
    if look_name and not look_id:
        look_id = get_look_id_from_name(look_name)

    result_format = demisto.args()["result_format"]
    limit = get_limit()
    fields = argToList(demisto.args().get("result_format"))

    contents = run_look(look_id, result_format, limit, fields)

    demisto.results(get_entries_for_search_results(contents, look_id, result_format, look_name))


def run_look(look_id, result_format, limit, fields):
    endpoint_url = f"/looks/{look_id}/run/{result_format}"
    params = {}
    if limit:
        params["limit"] = limit
    if fields:
        params["fields"] = fields
    return http_request("GET", endpoint_url, params=params, response_type=result_format)


def search_looks_command():
    command_args = ("space_id", "user_id")  # Possible command arguments
    args_dict = {k: demisto.args()[k] for k in command_args if k in demisto.args()}  # Get args that were passed

    # Arguments with special logic
    args_dict["limit"] = get_limit()
    if "name" in demisto.args():
        args_dict["title"] = demisto.args()["name"]

    contents = search_looks(args_dict)
    context = {f'Looker.Look(val.ID && val.ID === {look["ID"]})': look for look in contents}

    demisto.results(
        {
            "Type": entryTypes["note"],
            "ContentsFormat": formats["json"],
            "Contents": contents,
            "ReadableContentsFormat": formats["markdown"],
            "HumanReadable": tableToMarkdown("Look search results", contents, removeNull=True),
            "EntryContext": context,
        }
    )


def search_looks(args):
    endpoint_url = "/looks/search"
    params = {k: v for k, v in args.items() if v}
    params["fields"] = "id, title, space, updated_at"
    response = http_request("GET", endpoint_url, params=params)

    if not isinstance(response, list):
        response = [response]

    return [
        {
            "ID": look["id"],
            "Name": look["title"],
            "SpaceID": look["space"]["id"],
            "SpaceName": look["space"]["name"],
            "LastUpdated": look["updated_at"].replace("+00:00", "Z"),
        }
        for look in response
    ]


def run_inline_query_command():
    result_format = demisto.args()["result_format"]
    args_dict = get_query_args(demisto.args())

    args_dict["limit"] = get_limit()

    contents = run_inline_query(result_format, args_dict)

    demisto.results(get_entries_for_search_results(contents, result_format=result_format))


def run_inline_query(result_format, args_dict):
    return http_request(
        method="POST", url_suffix=f"/queries/run/{result_format}", data=json.dumps(args_dict), response_type=result_format
    )


def create_look_command():
    space_id = demisto.args()["look_space_id"]
    try:
        space_id = int(space_id)
    except ValueError:
        raise ValueError(f"space_id: invalid number: {space_id}")

    look_title = demisto.args()["look_title"]
    look_description = demisto.args().get("look_description")
    args_dict = get_query_args(demisto.args())

    create_query_response = create_query(args_dict)
    query_id = create_query_response["id"]

    contents = create_look(query_id, space_id, look_title, look_description)

    context = {f'Looker.Look(val.ID && val.ID === {contents["ID"]})': contents}

    demisto.results(
        {
            "Type": entryTypes["note"],
            "ContentsFormat": formats["json"],
            "Contents": contents,
            "ReadableContentsFormat": formats["markdown"],
            "HumanReadable": tableToMarkdown(f'Look "{look_title}" created successfully', contents, removeNull=True),
            "EntryContext": context,
        }
    )


def create_query(args_dict):
    return http_request(method="POST", url_suffix="/queries", data=json.dumps(args_dict))


def create_look(query_id, space_id, look_title, look_description=""):
    data = {"title": look_title, "query_id": query_id, "space_id": space_id}
    if look_description:
        data["look_description"] = look_description

    look = http_request(method="POST", url_suffix="/looks", data=json.dumps(data))

    return {
        "ID": look["id"],
        "Name": look["title"],
        "SpaceID": look["space"]["id"],
        "SpaceName": look["space"]["name"],
        "LastUpdated": look["updated_at"].replace("+00:00", "Z"),
    }


""" COMMANDS MANAGER / SWITCH PANEL """


def main():
    LOG(f"Command being called is {demisto.command()}")
    if not (CLIENT_ID and CLIENT_SECRET):
        raise DemistoException("API3 Client ID and Secret must be provided.")
    try:
        handle_proxy()
        verify_url(SERVER)
        HEADERS["Authorization"] = get_session_token(CLIENT_ID, CLIENT_SECRET)

        if demisto.command() == "test-module":
            test_module()
            demisto.results("ok")
        elif demisto.command() == "looker-run-look":
            run_look_command()
        elif demisto.command() == "looker-search-looks":
            search_looks_command()
        elif demisto.command() == "looker-run-inline-query":
            run_inline_query_command()
        elif demisto.command() == "looker-create-look":
            create_look_command()

    # Log exceptions
    except Exception as e:
        LOG(e)
        LOG(traceback.format_exc())
        LOG.print_log()
        if demisto.command() == "test-module":
            demisto.results(e)
        else:
            return_error(str(e))


# python2 uses __builtin__ python3 uses builtins
if __name__ == "__builtin__" or __name__ == "builtins":
    main()