Compare commits

...

1 Commits

Author SHA1 Message Date
f3nris 9fbe592421 feat(crowdstrike): version bounds and relative dates on device_count (1.7.0)
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
2026-10-19 20:09:31 +00:00
2 changed files with 100 additions and 8 deletions
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import json, os, sys, urllib.request, urllib.parse, urllib.error
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", "{}"))
@@ -61,6 +61,44 @@ RANGE_FILTERS = [
]
# 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.
@@ -82,7 +120,7 @@ def build_fql(inputs):
for name, prop, operator in RANGE_FILTERS:
value = inputs.get(name)
if value:
clauses.append(prop + ":" + operator + literal(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()
@@ -110,9 +148,59 @@ def count(tok, fql):
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