Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6a52f7356 | |||
| beefe047a7 | |||
| 505c518bf8 |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,156 @@
|
|||||||
|
import json, os, sys, time, hashlib, secrets, string, urllib.request, urllib.error
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
s = json.loads(os.environ.get("INTEGRATION_SECRETS") or "{}")
|
||||||
|
raw = str(s.get("url") or "").strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("no url configured — paste the tenant API URL (Cortex XDR > Settings > Configurations > API Keys > Copy URL)")
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
# The tenant URL is a bare host. Drop whatever was pasted after it (a stray
|
||||||
|
# /public_api/v1, a console path) so the API root is built exactly once.
|
||||||
|
scheme, _, rest = raw.partition("://")
|
||||||
|
base = scheme + "://" + rest.split("/")[0] + "/public_api/v1"
|
||||||
|
key = s.get("api_key", "")
|
||||||
|
kid = str(s.get("api_key_id", ""))
|
||||||
|
if not key or not kid:
|
||||||
|
raise ValueError("api_key and api_key_id are both required")
|
||||||
|
# Nonce and timestamp ride along in both modes, as the reference client does.
|
||||||
|
# A standard key travels as-is; an advanced one as sha256(key + nonce + ts).
|
||||||
|
nonce = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
|
ts = str(int(time.time()) * 1000)
|
||||||
|
headers = {
|
||||||
|
"x-xdr-auth-id": kid,
|
||||||
|
"x-xdr-nonce": nonce,
|
||||||
|
"x-xdr-timestamp": ts,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if str(s.get("auth_type") or "standard").lower() == "advanced":
|
||||||
|
headers["Authorization"] = hashlib.sha256((key + nonce + ts).encode("utf-8")).hexdigest()
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = key
|
||||||
|
return base, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _not_json(r, raw):
|
||||||
|
"""A 2xx that is not JSON means we are not talking to the XDR API at all."""
|
||||||
|
ctype = (r.headers.get("Content-Type") or "unknown").split(";")[0].strip()
|
||||||
|
head = raw[:160].decode("utf-8", "replace").replace("\n", " ").strip()
|
||||||
|
return (
|
||||||
|
"expected JSON from " + r.geturl() + ", got " + ctype + " (HTTP " + str(r.status) + "): " + head
|
||||||
|
+ " — check the configured url is the tenant API host"
|
||||||
|
+ " (https://api-<tenant>.xdr.<region>.paloaltonetworks.com), not the console URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, request_data):
|
||||||
|
base, headers = _client()
|
||||||
|
data = json.dumps({"request_data": request_data}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(base + path, data=data, headers=headers, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(_not_json(r, raw))
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def to_ms(v):
|
||||||
|
if v in (None, ""):
|
||||||
|
return None
|
||||||
|
s = str(v)
|
||||||
|
if s.isdigit():
|
||||||
|
return int(s)
|
||||||
|
try:
|
||||||
|
return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# input name -> (API filter field, operator). Values are comma-separated lists,
|
||||||
|
# which is the shape every one of these fields takes in the API.
|
||||||
|
LIST_FILTERS = [
|
||||||
|
("hostname", "hostname", "in"),
|
||||||
|
("alias", "alias", "in"),
|
||||||
|
("endpoint_id", "endpoint_id_list", "in"),
|
||||||
|
("ip_list", "ip_list", "in"),
|
||||||
|
("public_ip_list", "public_ip_list", "in"),
|
||||||
|
("group_name", "group_name", "in"),
|
||||||
|
("platform", "platform", "in"),
|
||||||
|
("dist_name", "dist_name", "in"),
|
||||||
|
("status", "endpoint_status", "IN"),
|
||||||
|
("isolate", "isolate", "in"),
|
||||||
|
("scan_status", "scan_status", "IN"),
|
||||||
|
("username", "username", "IN"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# input name -> (API filter field, operator). Values are epoch-ms bounds.
|
||||||
|
RANGE_FILTERS = [
|
||||||
|
("first_seen_after", "first_seen", "gte"),
|
||||||
|
("first_seen_before", "first_seen", "lte"),
|
||||||
|
("last_seen_after", "last_seen", "gte"),
|
||||||
|
("last_seen_before", "last_seen", "lte"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_filters(inputs):
|
||||||
|
filters = []
|
||||||
|
for name, field, operator in LIST_FILTERS:
|
||||||
|
values = csv(inputs.get(name))
|
||||||
|
if values:
|
||||||
|
filters.append({"field": field, "operator": operator, "value": values})
|
||||||
|
for name, field, operator in RANGE_FILTERS:
|
||||||
|
ms = to_ms(inputs.get(name))
|
||||||
|
if ms is not None:
|
||||||
|
filters.append({"field": field, "operator": operator, "value": ms})
|
||||||
|
# Escape hatch for any filter this command does not name explicitly: a JSON
|
||||||
|
# array of {field, operator, value} blocks, appended as-is.
|
||||||
|
raw = inputs.get("filters")
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
extra = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
|
except ValueError:
|
||||||
|
extra = None
|
||||||
|
if not isinstance(extra, list) or not all(isinstance(f, dict) for f in extra):
|
||||||
|
raise ValueError("filters must be a JSON array of {field, operator, value} objects, got: " + str(raw)[:120])
|
||||||
|
filters.extend(extra)
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def count(filters):
|
||||||
|
# The endpoints grid answers with reply.total_count: the number of endpoints
|
||||||
|
# matching the filters, independent of the page asked for. search_to=1 keeps
|
||||||
|
# the page — and so the payload — to a single endpoint record.
|
||||||
|
request_data = {"search_from": 0, "search_to": 1, "filters": filters}
|
||||||
|
reply = post("/endpoints/get_endpoint/", request_data).get("reply") or {}
|
||||||
|
return int(reply.get("total_count") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
filters = build_filters(inputs)
|
||||||
|
result = {"count": count(filters), "filters": filters}
|
||||||
|
# The fleet total is the denominator the filtered count is read against: it
|
||||||
|
# turns a bare number into a proportion, and makes a filter that matched the
|
||||||
|
# whole estate — because it was too broad — visible instead of plausible.
|
||||||
|
result["total"] = count([]) if filters else result["count"]
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: crowdstrike
|
id: crowdstrike
|
||||||
name: CrowdStrike Falcon
|
name: CrowdStrike Falcon
|
||||||
version: 1.3.0
|
version: 1.4.0
|
||||||
description: "CrowdStrike Falcon (OAuth2 API) — full IR coverage: device/IOC/process enrichment, detections & cases, host groups, Real Time Response, ML/IOA exclusions, quarantine, Spotlight/CVE, ODS scans, CSPM, users, IOA rules, CNAPP, and Fusion workflows."
|
description: "CrowdStrike Falcon (OAuth2 API) — full IR coverage: device/IOC/process enrichment, detections & cases, host groups, Real Time Response, ML/IOA exclusions, quarantine, Spotlight/CVE, ODS scans, CSPM, users, IOA rules, CNAPP, and Fusion workflows."
|
||||||
changelog: "1.3.0 — search_detections takes a created_from timestamp, folded into the FQL as created_timestamp, and declares it as the ingestion watermark input. An alert rule can now fetch incrementally instead of re-reading the whole window every run: this API expresses its time bound inside the FQL, so the watermark used to have nowhere to go but the filter input, where it replaced the query rather than narrowing it. filter now carries the scoping only. 1.2.4 — Fixed two silent defects in the search_detections OCSF mapper: severity was a ternary that resolved to nothing, so every alert kept the alert rule's default severity instead of Falcon's — it is now a plain path to severity_name, whose words the engine reads natively; and no entry fed the OCSF `time` field, so detected_at was never stamped and MTTD measured the collection delay as zero. 1.2.3 — Documentation wording cleanup. 1.2.2 — Corrected the search_detections OCSF mapper against the Falcon Raptor alert schema: domain is device.hostinfo.domain, dropped non-existent fields, added scenario/alleged_filetype/cid. 1.2.1 — Marked search_detections as an ingestion source (ingest hints: results_path=resources, dedup_key=composite_id) so the alert-rule editor pre-fills it. 1.2.0 — Added a pre-built OCSF mapper for search_detections (Falcon unified alerts → OCSF: device, finding, MITRE, actor/parent process, file and IOC). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Expanded to 81 commands (host groups, cases, RTR files/scripts/responders, ML/IOA exclusions, quarantine, Spotlight host-by-vuln/CVE, ODS scans, CSPM, users, IOA rules, CNAPP, identity/mobile detection resolve, and workflows). 1.0.0 — Initial release: device/detection enrichment, Spotlight, IOC management, contain/lift, and core RTR."
|
changelog: "1.4.0 — New device_count command: counts devices matching filters without listing them. The device query already answers with meta.pagination.total — the number of matches, independent of the page asked for — so device_count asks for a one-ID page and reads it, where search-device resolves every matching device in full through /devices/entities/devices/v2 to reach the same figure, and only filters on four properties. Filters cover sixteen device FQL properties (hostname, device id, local and external IP, platform, OS version, role, containment status, sensor version, AD domain/OU/site, serial, tags, host groups, RFM), add first_seen/last_seen bounds, and leave a raw FQL `filter` escape hatch for the rest. Several values on one property become an OR group, parenthesised so it binds before the AND that joins it to the others — the reference client omits those parentheses and leaves the precedence to the server. Values are quoted with the single quote escaped, so a hostname carrying an apostrophe cannot end the literal early and change what the query counts. The estate `total` comes back next to `count` so the figure reads as a proportion, and a filter too broad to mean anything shows as matching everything rather than passing for an answer. | 1.3.0 — search_detections takes a created_from timestamp, folded into the FQL as created_timestamp, and declares it as the ingestion watermark input. An alert rule can now fetch incrementally instead of re-reading the whole window every run: this API expresses its time bound inside the FQL, so the watermark used to have nowhere to go but the filter input, where it replaced the query rather than narrowing it. filter now carries the scoping only. 1.2.4 — Fixed two silent defects in the search_detections OCSF mapper: severity was a ternary that resolved to nothing, so every alert kept the alert rule's default severity instead of Falcon's — it is now a plain path to severity_name, whose words the engine reads natively; and no entry fed the OCSF `time` field, so detected_at was never stamped and MTTD measured the collection delay as zero. 1.2.3 — Documentation wording cleanup. 1.2.2 — Corrected the search_detections OCSF mapper against the Falcon Raptor alert schema: domain is device.hostinfo.domain, dropped non-existent fields, added scenario/alleged_filetype/cid. 1.2.1 — Marked search_detections as an ingestion source (ingest hints: results_path=resources, dedup_key=composite_id) so the alert-rule editor pre-fills it. 1.2.0 — Added a pre-built OCSF mapper for search_detections (Falcon unified alerts → OCSF: device, finding, MITRE, actor/parent process, file and IOC). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Expanded to 81 commands (host groups, cases, RTR files/scripts/responders, ML/IOA exclusions, quarantine, Spotlight host-by-vuln/CVE, ODS scans, CSPM, users, IOA rules, CNAPP, identity/mobile detection resolve, and workflows). 1.0.0 — Initial release: device/detection enrichment, Spotlight, IOC management, contain/lift, and core RTR."
|
||||||
category: endpoint
|
category: endpoint
|
||||||
|
|
||||||
# Per-instance configuration. Scripts obtain an OAuth2 bearer token from
|
# Per-instance configuration. Scripts obtain an OAuth2 bearer token from
|
||||||
@@ -48,6 +48,36 @@ commands:
|
|||||||
required: []
|
required: []
|
||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
|
- id: device_count
|
||||||
|
name: crowdstrike-device-count
|
||||||
|
description: "Count managed devices (hosts) matching filters, without listing them. The device query answers with meta.pagination.total — the number of matches, independent of the page asked for — so one call with a single-ID page returns the figure, where crowdstrike-search-device would resolve every matching device in full to arrive at it. Returns {count, total, filter}: count is the number of matching devices, total the whole estate, and filter the FQL actually sent."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
hostname: { type: string, description: "Comma-separated hostnames (exact match; several values are ORed)." }
|
||||||
|
device_id: { type: string, description: "Comma-separated device (AID) IDs." }
|
||||||
|
local_ip: { type: string, description: "Comma-separated local IP addresses." }
|
||||||
|
external_ip: { type: string, description: "Comma-separated external IP addresses." }
|
||||||
|
platform_name: { type: string, description: "Comma-separated platforms: Windows, Mac, Linux." }
|
||||||
|
os_version: { type: string, description: "Comma-separated OS versions, e.g. Windows Server 2019." }
|
||||||
|
product_type_desc: { type: string, description: "Comma-separated device roles: Workstation, Server, Domain Controller." }
|
||||||
|
status: { type: string, description: "Comma-separated containment statuses: normal, containment_pending, contained, lift_containment_pending." }
|
||||||
|
agent_version: { type: string, description: "Comma-separated sensor versions." }
|
||||||
|
machine_domain: { type: string, description: "Comma-separated Active Directory domains." }
|
||||||
|
ou: { type: string, description: "Comma-separated Active Directory organizational units." }
|
||||||
|
site_name: { type: string, description: "Comma-separated AD site names." }
|
||||||
|
serial_number: { type: string, description: "Comma-separated hardware serial numbers." }
|
||||||
|
tags: { type: string, description: "Comma-separated sensor or Falcon grouping tags." }
|
||||||
|
groups: { type: string, description: "Comma-separated host group IDs (from crowdstrike-list-host-groups)." }
|
||||||
|
reduced_functionality_mode: { type: string, description: "RFM state: yes or no. Counting the yes side is how you size a sensor fleet running degraded." }
|
||||||
|
first_seen_after: { type: string, description: "Lower bound on first_seen, UTC (e.g. 2026-08-21T00:00:00Z) — devices enrolled since that instant." }
|
||||||
|
first_seen_before: { type: string, description: "Upper bound on first_seen, UTC." }
|
||||||
|
last_seen_after: { type: string, description: "Lower bound on last_seen, UTC — devices seen since that instant." }
|
||||||
|
last_seen_before: { type: string, description: "Upper bound on last_seen, UTC — devices silent since that instant." }
|
||||||
|
filter: { type: string, description: "Any other criterion, as raw FQL, ANDed with the rest (e.g. kernel_version:'5.15*')." }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
|
|
||||||
- id: get_device_details
|
- id: get_device_details
|
||||||
name: crowdstrike-get-device-details
|
name: crowdstrike-get-device-details
|
||||||
description: "Retrieve full details for one or more devices by ID."
|
description: "Retrieve full details for one or more devices by ID."
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
SECRETS = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
INPUTS = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
BASE = SECRETS.get("base_url", "https://api.crowdstrike.com").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
data = urllib.parse.urlencode({"client_id": SECRETS.get("client_id", ""), "client_secret": SECRETS.get("client_secret", "")}).encode()
|
||||||
|
req = urllib.request.Request(BASE + "/oauth2/token", data=data, headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read()).get("access_token", "")
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, tok, params=None, body=None):
|
||||||
|
url = BASE + path
|
||||||
|
if params:
|
||||||
|
# params is a list of (key, value) tuples to allow repeated keys (e.g. ids)
|
||||||
|
url += "?" + urllib.parse.urlencode(params)
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Authorization": "Bearer " + tok, "Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# input name -> FQL property. Values are comma-separated; several values on one
|
||||||
|
# property become an OR group, and the groups are ANDed together.
|
||||||
|
STRING_FILTERS = [
|
||||||
|
("hostname", "hostname"),
|
||||||
|
("device_id", "device_id"),
|
||||||
|
("local_ip", "local_ip"),
|
||||||
|
("external_ip", "external_ip"),
|
||||||
|
("platform_name", "platform_name"),
|
||||||
|
("os_version", "os_version"),
|
||||||
|
("product_type_desc", "product_type_desc"),
|
||||||
|
("status", "status"),
|
||||||
|
("agent_version", "agent_version"),
|
||||||
|
("machine_domain", "machine_domain"),
|
||||||
|
("ou", "ou"),
|
||||||
|
("site_name", "site_name"),
|
||||||
|
("serial_number", "serial_number"),
|
||||||
|
("tags", "tags"),
|
||||||
|
("groups", "groups"),
|
||||||
|
("reduced_functionality_mode", "reduced_functionality_mode"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# input name -> (FQL property, operator). Values are UTC timestamps.
|
||||||
|
RANGE_FILTERS = [
|
||||||
|
("first_seen_after", "first_seen", ">="),
|
||||||
|
("first_seen_before", "first_seen", "<="),
|
||||||
|
("last_seen_after", "last_seen", ">="),
|
||||||
|
("last_seen_before", "last_seen", "<="),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def literal(v):
|
||||||
|
# FQL quotes strings with single quotes, so a value carrying one would end
|
||||||
|
# the literal early and change what the query means.
|
||||||
|
return "'" + str(v).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def build_fql(inputs):
|
||||||
|
clauses = []
|
||||||
|
for name, prop in STRING_FILTERS:
|
||||||
|
values = csv(inputs.get(name))
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
if len(values) == 1:
|
||||||
|
clauses.append(prop + ":" + literal(values[0]))
|
||||||
|
else:
|
||||||
|
# ',' is OR in FQL. Parenthesised so the group binds before the '+'
|
||||||
|
# that ANDs it to the other clauses.
|
||||||
|
clauses.append("(" + ",".join(prop + ":" + literal(v) for v in values) + ")")
|
||||||
|
for name, prop, operator in RANGE_FILTERS:
|
||||||
|
value = inputs.get(name)
|
||||||
|
if value:
|
||||||
|
clauses.append(prop + ":" + operator + literal(value))
|
||||||
|
# Escape hatch for any property this command does not name explicitly: raw
|
||||||
|
# FQL, ANDed with the rest.
|
||||||
|
raw = str(inputs.get("filter") or "").strip()
|
||||||
|
if raw:
|
||||||
|
clauses.append("(" + raw + ")")
|
||||||
|
return "+".join(clauses)
|
||||||
|
|
||||||
|
|
||||||
|
def count(tok, fql):
|
||||||
|
# The device query answers with meta.pagination.total: the number of devices
|
||||||
|
# matching the filter, independent of the page asked for. limit=1 keeps the
|
||||||
|
# page — and so the payload — to a single device ID.
|
||||||
|
params = [("limit", 1)]
|
||||||
|
if fql:
|
||||||
|
params.insert(0, ("filter", fql))
|
||||||
|
reply = call("GET", "/devices/queries/devices/v1", tok, params=params)
|
||||||
|
total = ((reply.get("meta") or {}).get("pagination") or {}).get("total")
|
||||||
|
if total is None:
|
||||||
|
# devices/v1 stops reporting a total on an estate it cannot page to the
|
||||||
|
# end of (it refuses past 10000). The scroll query has no such ceiling.
|
||||||
|
reply = call("GET", "/devices/queries/devices-scroll/v1", tok, params=params)
|
||||||
|
total = ((reply.get("meta") or {}).get("pagination") or {}).get("total")
|
||||||
|
if total is None:
|
||||||
|
raise ValueError("the API returned no meta.pagination.total for this query")
|
||||||
|
return int(total)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tok = token()
|
||||||
|
fql = build_fql(INPUTS)
|
||||||
|
result = {"count": count(tok, fql), "filter": fql}
|
||||||
|
# The estate total is the denominator the filtered count is read against: it
|
||||||
|
# turns a bare number into a proportion, and makes a filter that matched the
|
||||||
|
# whole estate — because it was too broad — visible instead of plausible.
|
||||||
|
result["total"] = count(tok, "") if fql else result["count"]
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
id: harfanglab
|
id: harfanglab
|
||||||
name: HarfangLab EDR
|
name: HarfangLab EDR
|
||||||
version: 1.5.0
|
version: 1.6.0
|
||||||
description: "HarfangLab EDR — endpoint detection & response: alert/threat ingestion, endpoint enrichment, isolation, threat-intelligence (IOC/whitelist), telemetry hunting and forensic collection jobs."
|
description: "HarfangLab EDR — endpoint detection & response: alert/threat ingestion, endpoint enrichment, isolation, threat-intelligence (IOC/whitelist), telemetry hunting and forensic collection jobs."
|
||||||
changelog: "1.5.0 — Declare the risk tier on the 71 commands that carried none: 39 read, 24 safe_write, 8 destructive. An unset risk is read as destructive by the agent approval gate, so the 63 that only read or only annotate sat behind the same prompt as the ones that change the estate. deisolate_from_threat and unquarantine_file move from safe_write to destructive in the same pass: undoing a containment mid-incident is its own decision, and is tiered like the containment it lifts. | 1.4.1 — get_alerts now maps image_name, the one process path present on every alert subtype and the only one on those carrying neither a process object nor an event log (yara, binary, ioc). HarfangLab prefixes it with the matched threat name in brackets, so both halves are split out: the clean path feeds actor.process.file.path and its basename actor.process.name, and the threat name feeds malware.name — which now surfaces on every subtype rather than only on Defender event-log detections. Each half is guarded on the bracket being present so an unprefixed path is never written into malware.name. 1.4.0 — Mapper coverage from real payloads: get_alerts now maps the Windows event-log payload sigma alerts carry (alert_subtype = eventlog), which was entirely unmapped — Defender 1116/1117 yields the malware name, classification, severity and provider, the detected file, the process that touched it and the acting user, plus the log provider and event code. Also added the agent IP, DNS domain, device type, agent version and groups, the tenant, the alert subtype and threat type, the string severity and the event time. get_threats gained the threat level (previously never reaching the OCSF document at all), the event time and the top agent's reachability. 1.3.1 — Flagged get_alerts as ingestion-ready (ingest hints: results_path/dedup/incremental on from_date) so it appears in the recommended ingestion commands when creating an instance. 1.3.0 — Response & triage expansion: kill/dump process, on-demand AV/YARA scans, file/service/scheduled-task deletion, quarantine management, threat-level isolation, job cancel/relaunch/wait; alert search (get_alerts) and full alert/threat triage (tag, comments, notes, status, level, history, details); exhaustive alert OCSF mappers; reworked get_threats mapper (fixed MITRE tactics map handling, full top-agent/user/rule and counts coverage, rule-based incident title/description). 1.2.2 — Documentation wording cleanup. 1.2.1 — Corrected the get_threats OCSF mapper to native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics) instead of enriched-only arrays; added agent.osversion to get_security_events. 1.2.0 — Added ingestion commands get_security_events (alerts) and get_threats with bundled OCSF mappers and ingest hints (results_path/dedup/incremental). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Command names prefixed with 'harfanglab-' (e.g. harfanglab-isolate-endpoint) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: endpoint/agent management, isolation, policy assignment, IOC & whitelist management, security-event triage, telemetry hunting (processes, network, DNS, authentications, binaries, event logs), threat hunting by hash, and forensic collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services, processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) with their result retrieval commands. Compatible with HarfangLab EDR 2.13.7+."
|
changelog: "1.6.0 — New endpoint_count command: counts agents matching filters without listing them. The HarfangLab agent collection is DRF-paginated, so its `count` field already carries the filtered total; endpoint_count asks for limit=1 and reads it, instead of pulling up to 10000 agent records back through the playbook the way endpoint_search does. Filters cover the documented agent query parameters (hostname, os, version, status, isolation_state, tenant, network), add lastseen/firstseen bounds, and leave a raw `filters` escape hatch for the rest. The API silently drops a parameter it does not recognise as a filter — which turns a typo into a plausible-looking fleet-wide total — so the fleet `total` is returned next to `count` to make that case visible. | 1.5.0 — Declare the risk tier on the 71 commands that carried none: 39 read, 24 safe_write, 8 destructive. An unset risk is read as destructive by the agent approval gate, so the 63 that only read or only annotate sat behind the same prompt as the ones that change the estate. deisolate_from_threat and unquarantine_file move from safe_write to destructive in the same pass: undoing a containment mid-incident is its own decision, and is tiered like the containment it lifts. | 1.4.1 — get_alerts now maps image_name, the one process path present on every alert subtype and the only one on those carrying neither a process object nor an event log (yara, binary, ioc). HarfangLab prefixes it with the matched threat name in brackets, so both halves are split out: the clean path feeds actor.process.file.path and its basename actor.process.name, and the threat name feeds malware.name — which now surfaces on every subtype rather than only on Defender event-log detections. Each half is guarded on the bracket being present so an unprefixed path is never written into malware.name. 1.4.0 — Mapper coverage from real payloads: get_alerts now maps the Windows event-log payload sigma alerts carry (alert_subtype = eventlog), which was entirely unmapped — Defender 1116/1117 yields the malware name, classification, severity and provider, the detected file, the process that touched it and the acting user, plus the log provider and event code. Also added the agent IP, DNS domain, device type, agent version and groups, the tenant, the alert subtype and threat type, the string severity and the event time. get_threats gained the threat level (previously never reaching the OCSF document at all), the event time and the top agent's reachability. 1.3.1 — Flagged get_alerts as ingestion-ready (ingest hints: results_path/dedup/incremental on from_date) so it appears in the recommended ingestion commands when creating an instance. 1.3.0 — Response & triage expansion: kill/dump process, on-demand AV/YARA scans, file/service/scheduled-task deletion, quarantine management, threat-level isolation, job cancel/relaunch/wait; alert search (get_alerts) and full alert/threat triage (tag, comments, notes, status, level, history, details); exhaustive alert OCSF mappers; reworked get_threats mapper (fixed MITRE tactics map handling, full top-agent/user/rule and counts coverage, rule-based incident title/description). 1.2.2 — Documentation wording cleanup. 1.2.1 — Corrected the get_threats OCSF mapper to native threat fields (top_agents/top_impacted_users/top_rules/mitre_tactics) instead of enriched-only arrays; added agent.osversion to get_security_events. 1.2.0 — Added ingestion commands get_security_events (alerts) and get_threats with bundled OCSF mappers and ingest hints (results_path/dedup/incremental). 1.1.1 — Added test_connection for the instance Test button. 1.1.0 — Command names prefixed with 'harfanglab-' (e.g. harfanglab-isolate-endpoint) for easier toolbox search; command IDs unchanged. 1.0.0 — Initial release: endpoint/agent management, isolation, policy assignment, IOC & whitelist management, security-event triage, telemetry hunting (processes, network, DNS, authentications, binaries, event logs), threat hunting by hash, and forensic collection jobs (pipes, prefetch, run keys, scheduled tasks, drivers, services, processes, network, sessions, WMI, IOC scan, artifacts, RAM dump) with their result retrieval commands. Compatible with HarfangLab EDR 2.13.7+."
|
||||||
category: endpoint
|
category: endpoint
|
||||||
|
|
||||||
# Per-instance configuration. Scripts use <url> as the API base and call /api/... paths.
|
# Per-instance configuration. Scripts use <url> as the API base and call /api/... paths.
|
||||||
@@ -83,6 +83,32 @@ commands:
|
|||||||
hostname: { type: string, description: "Hostname to filter agents by" }
|
hostname: { type: string, description: "Hostname to filter agents by" }
|
||||||
required: []
|
required: []
|
||||||
outputs_schema: { properties: {} }
|
outputs_schema: { properties: {} }
|
||||||
|
- id: endpoint_count
|
||||||
|
name: harfanglab-endpoint-count
|
||||||
|
description: "Count endpoints (agents) matching filters, without listing them. Returns {count, total, filters}: count is the number of matching agents, total the whole fleet. An unrecognised filter is silently ignored by the API, so compare count against total before trusting a figure."
|
||||||
|
risk: read
|
||||||
|
inputs_schema:
|
||||||
|
properties:
|
||||||
|
hostname: { type: string, description: "Exact hostname" }
|
||||||
|
domainname: { type: string, description: "Exact DNS domain name" }
|
||||||
|
domain: { type: string, description: "Exact Windows domain" }
|
||||||
|
ipaddress: { type: string, description: "Exact internal IP address" }
|
||||||
|
external_ipaddress: { type: string, description: "Exact external IP address" }
|
||||||
|
ostype: { type: string, description: "Operating system type (windows, linux, macos)" }
|
||||||
|
osproducttype: { type: string, description: "OS product type, e.g. Server or Workstation" }
|
||||||
|
osversion: { type: string, description: "Exact OS version string" }
|
||||||
|
producttype: { type: string, description: "Exact product type" }
|
||||||
|
version: { type: string, description: "Exact agent version" }
|
||||||
|
status: { type: string, description: "Agent status, e.g. online or offline" }
|
||||||
|
isolation_state: { type: boolean, description: "true counts isolated agents, false counts non-isolated ones" }
|
||||||
|
tenant: { type: string, description: "Exact tenant identifier" }
|
||||||
|
lastseen_after: { type: string, description: "ISO8601 lower bound on lastseen — agents seen since that instant" }
|
||||||
|
lastseen_before: { type: string, description: "ISO8601 upper bound on lastseen — agents not seen since that instant" }
|
||||||
|
firstseen_after: { type: string, description: "ISO8601 lower bound on firstseen — agents enrolled since that instant" }
|
||||||
|
firstseen_before: { type: string, description: "ISO8601 upper bound on firstseen" }
|
||||||
|
filters: { type: string, description: "Any other API filter, as k=v pairs joined by & (e.g. groups__name=Serveurs&osmajor=10)" }
|
||||||
|
required: []
|
||||||
|
outputs_schema: { properties: {} }
|
||||||
- id: isolate_endpoint
|
- id: isolate_endpoint
|
||||||
name: harfanglab-isolate-endpoint
|
name: harfanglab-isolate-endpoint
|
||||||
description: Network-isolate an endpoint (agent) by agent ID.
|
description: Network-isolate an endpoint (agent) by agent ID.
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import json, os, sys, urllib.request, urllib.parse, urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, url, headers, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def qs(params):
|
||||||
|
clean = {}
|
||||||
|
for k, v in params.items():
|
||||||
|
if v is None or v == "" or v == []:
|
||||||
|
continue
|
||||||
|
if isinstance(v, bool):
|
||||||
|
v = "true" if v else "false"
|
||||||
|
clean[k] = v
|
||||||
|
return ("?" + urllib.parse.urlencode(clean)) if clean else ""
|
||||||
|
|
||||||
|
|
||||||
|
def csv(v):
|
||||||
|
return [x.strip() for x in str(v or "").split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# Agent fields HarfangLab exposes as exact-match query filters on
|
||||||
|
# /api/data/endpoint/Agent/. Input name == API filter name.
|
||||||
|
EXACT_FILTERS = [
|
||||||
|
"hostname",
|
||||||
|
"domainname",
|
||||||
|
"domain",
|
||||||
|
"ipaddress",
|
||||||
|
"external_ipaddress",
|
||||||
|
"ostype",
|
||||||
|
"osproducttype",
|
||||||
|
"osversion",
|
||||||
|
"producttype",
|
||||||
|
"version",
|
||||||
|
"status",
|
||||||
|
"isolation_state",
|
||||||
|
"tenant",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Convenience bounds mapped onto Django REST Framework range lookups.
|
||||||
|
RANGE_FILTERS = {
|
||||||
|
"lastseen_after": "lastseen__gte",
|
||||||
|
"lastseen_before": "lastseen__lte",
|
||||||
|
"firstseen_after": "firstseen__gte",
|
||||||
|
"firstseen_before": "firstseen__lte",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
return str(v).strip().lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
secrets = json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||||
|
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||||
|
base = secrets.get("url", "").rstrip("/")
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Token " + secrets.get("api_token", ""),
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
# === per-command logic ===
|
||||||
|
filters = {}
|
||||||
|
for field in EXACT_FILTERS:
|
||||||
|
value = inputs.get(field)
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
filters[field] = to_bool(value) if field == "isolation_state" else value
|
||||||
|
for field, lookup in RANGE_FILTERS.items():
|
||||||
|
value = inputs.get(field)
|
||||||
|
if value:
|
||||||
|
filters[lookup] = value
|
||||||
|
# Escape hatch for any filter this command does not name explicitly,
|
||||||
|
# e.g. "groups__name=Serveurs&osmajor=10".
|
||||||
|
for tok in str(inputs.get("filters") or "").split("&"):
|
||||||
|
kv = tok.split("=", 1)
|
||||||
|
if len(kv) == 2 and kv[0].strip():
|
||||||
|
filters[kv[0].strip()] = kv[1]
|
||||||
|
|
||||||
|
# The agent list is a DRF paginated collection: `count` is the total number
|
||||||
|
# of matches, independent of the page size. limit=1 keeps the payload tiny.
|
||||||
|
agents = base + "/api/data/endpoint/Agent/"
|
||||||
|
matched = request("GET", agents + qs(dict(filters, limit=1, offset=0)), headers)
|
||||||
|
result = {"count": matched.get("count", 0), "filters": filters}
|
||||||
|
|
||||||
|
# The API silently drops a query parameter it does not recognise as a
|
||||||
|
# filter, which yields the unfiltered total dressed up as a filtered count.
|
||||||
|
# Returning the fleet total alongside makes that failure visible.
|
||||||
|
if filters:
|
||||||
|
everything = request("GET", agents + qs({"limit": 1, "offset": 0}), headers)
|
||||||
|
result["total"] = everything.get("count", 0)
|
||||||
|
else:
|
||||||
|
result["total"] = result["count"]
|
||||||
|
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
Reference in New Issue
Block a user