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
| ID | Looker |
|---|---|
| Provider | |
| Category | Analytics & SIEM |
| From Version | 5.0.0 |
| Docker Image | demisto/python3:3.12.13.10325753 |
| Supported Modules | Agentix XSIAM |
README
This integration was integrated and tested with Looker version 6.10.20.
Use Cases
- Ingest query results as incidents.
- Run a custom (inline) query as part of a playbook.
- 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:
- Log in to the Looker web interface with an account that is permitted to manage users.
- At the top of the page, click on the “Admin” drop down and select “Users”
- Select the user you would like to generate the API3 key for.
- Go to “API3 Keys” and select “Edit Keys”
- 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
- Click on a look.
- The number at the end of the URL is the ID of the look.
Option B: Cortex XSOAR commands
- Configure the Looker integration without fetching incidents, or filling in the parameter.
- Run the
looker-search-queriesorlooker-search-lookscommand. - The ID will be part of the results (among other look details).
Get model and view names from an explore’s URL:
- Navigate to the explore.
- The URL will be formatted like this:
https://<looker server>/explore/<model>/<view>
Get a field’s SQL name (for command arguments):
- Navigate to the explore.
- Click a field.
- 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
- Navigate to Settings > Integrations > Servers & Services.
- Search for Looker.
- 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
- 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 IDclient_secret— API3 Client Secretcredentials_api3_client— API3 Client IDunsecure— Trust any certificate (not secure)proxy— Use system proxy settings
Commands (4)
-
looker-create-lookCreates a look from a query.
-
looker-run-inline-queryRuns a query by defining it in the command arguments, rather than a saved query in looker.
-
looker-run-lookRuns a saved look and returns the results in the specified format.
-
looker-search-looksRetrieves 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()