9fbe592421
agent_version_lt/lte/gt/gte count the sensors below or above a build. FQL compares versions as text, so the script scrolls the matching device IDs, reads their versions and compares number by number. first_seen / last_seen bounds also take a duration relative to now (24h, 7d). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD
220 lines
8.4 KiB
Python
220 lines
8.4 KiB
Python
import datetime, json, os, re, 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", "<="),
|
|
]
|
|
|
|
|
|
# A bound on first_seen / last_seen can also be relative to now ("24h", "7d",
|
|
# "30m"), so a scheduled playbook counts the sensors seen in the last day without
|
|
# computing a date. It is turned into the UTC instant here.
|
|
RELATIVE = re.compile(r"^\s*(\d+)\s*([mhd])\s*$", re.I)
|
|
UNITS = {"m": "minutes", "h": "hours", "d": "days"}
|
|
|
|
|
|
def instant(v):
|
|
m = RELATIVE.match(str(v))
|
|
if not m:
|
|
return v
|
|
delta = datetime.timedelta(**{UNITS[m.group(2).lower()]: int(m.group(1))})
|
|
return (datetime.datetime.now(datetime.timezone.utc) - delta).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
# Sensor version bounds. FQL compares agent_version as text, where 7.9 sorts
|
|
# above 7.18, so these are compared here, number by number, over the devices the
|
|
# other filters keep.
|
|
VERSION_BOUNDS = {
|
|
"agent_version_lt": lambda v, t: v < t,
|
|
"agent_version_lte": lambda v, t: v <= t,
|
|
"agent_version_gt": lambda v, t: v > t,
|
|
"agent_version_gte": lambda v, t: v >= t,
|
|
}
|
|
SCROLL = 5000
|
|
DETAILS = 1000
|
|
|
|
|
|
def parse_version(v):
|
|
parts = re.findall(r"\d+", str(v or ""))
|
|
return tuple(int(x) for x in parts) if parts else None
|
|
|
|
|
|
def pad(a, b):
|
|
n = max(len(a), len(b))
|
|
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
|
|
|
|
|
|
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(instant(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 version_bounds(inputs):
|
|
bounds = {}
|
|
for field in VERSION_BOUNDS:
|
|
value = str(inputs.get(field) or "").strip()
|
|
if value:
|
|
target = parse_version(value)
|
|
if target is None:
|
|
raise ValueError(field + " must be a version such as 7.18.18803, got: " + repr(value))
|
|
bounds[field] = target
|
|
return bounds
|
|
|
|
|
|
def count_by_version(tok, fql, bounds):
|
|
"""Every device ID the filter keeps, then their sensor versions, compared
|
|
here. A device with no readable version is left out and counted apart."""
|
|
ids, offset = [], None
|
|
while True:
|
|
params = [("limit", SCROLL)]
|
|
if fql:
|
|
params.insert(0, ("filter", fql))
|
|
if offset:
|
|
params.append(("offset", offset))
|
|
reply = call("GET", "/devices/queries/devices-scroll/v1", tok, params=params)
|
|
page = reply.get("resources") or []
|
|
ids.extend(page)
|
|
pagination = (reply.get("meta") or {}).get("pagination") or {}
|
|
offset = pagination.get("offset")
|
|
if not page or not offset or len(ids) >= int(pagination.get("total") or 0):
|
|
break
|
|
matched, unreadable = 0, 0
|
|
for i in range(0, len(ids), DETAILS):
|
|
reply = call("POST", "/devices/entities/devices/v2", tok, body={"ids": ids[i:i + DETAILS]})
|
|
for device in reply.get("resources") or []:
|
|
version = parse_version(device.get("agent_version"))
|
|
if version is None:
|
|
unreadable += 1
|
|
elif all(VERSION_BOUNDS[f](*pad(version, t)) for f, t in bounds.items()):
|
|
matched += 1
|
|
return matched, len(ids), unreadable
|
|
|
|
|
|
def main():
|
|
tok = token()
|
|
fql = build_fql(INPUTS)
|
|
bounds = version_bounds(INPUTS)
|
|
if bounds:
|
|
matched, scanned, unreadable = count_by_version(tok, fql, bounds)
|
|
result = {"count": matched, "filter": fql,
|
|
"agent_version_bounds": {f: INPUTS[f] for f in bounds},
|
|
"scanned": scanned, "unreadable_version": unreadable}
|
|
result["total"] = count(tok, "")
|
|
print(json.dumps(result))
|
|
return
|
|
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)
|