From 9fbe5924213d27d12f59170d546e9a7614f4bf15 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Mon, 19 Oct 2026 20:09:31 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TgjV26rz6VcP262dX4vBrD --- integrations/crowdstrike/manifest.yaml | 16 ++-- .../crowdstrike/scripts/device_count.py | 92 ++++++++++++++++++- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/integrations/crowdstrike/manifest.yaml b/integrations/crowdstrike/manifest.yaml index 333aee8..409343e 100644 --- a/integrations/crowdstrike/manifest.yaml +++ b/integrations/crowdstrike/manifest.yaml @@ -1,8 +1,8 @@ id: crowdstrike name: CrowdStrike Falcon -version: 1.6.3 +version: 1.7.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." -changelog: "1.6.3 — resolve_detection fails when Falcon changed nothing. The alerts PATCH answers 200 to an update it refused, with the reasons in errors and meta.writes.resources_affected at 0, and the command passed that on as a success: an incident could be closed in Riposte and stay open in the console. It now exits in error with Falcon's errors and the ids it sent (the PATCH takes composite ids, :). A call with nothing to change is refused before reaching Falcon. The command applies to every unified alert, NG-SIEM and third-party detections included. | 1.6.2 — search_ngsiem_detections reads products thirdparty and ngsiem by default, set by a new products input. On a real tenant the NG-SIEM detections of the Unified Detections page — here FortiGate IPS hits — are filed under product thirdparty, and product ngsiem matched nothing, so 1.6.0 returned an empty list. Its mapper is rewritten from those payloads: source product and vendor, the host Falcon matched the detection to, source and target IPs and ports, protocol, the vendor signature id as the analytic uid, the Falcon priority as risk score, the console link, and the source event time as OCSF time. Third-party detections carry no description, so the incident's is built from the source product, the signature and the connection. | 1.6.1 — search_detections and search_ngsiem_detections return a query block next to resources: the FQL actually sent, the total Falcon counts for it and any errors the query carried. A search that matched nothing used to come back as a bare empty list, which could not be told apart from a wrong region, a filter Falcon read differently or an API client that sees no alerts. | 1.6.0 — New search_ngsiem_detections command for Next-Gen SIEM detections. Falcon files the detections of NG-SIEM correlation rules in the same unified Alerts API as the EDR, under product 'ngsiem', so search_detections' default epp/ldt scope never returned them, and its mapper reads device, process and file blocks an NG-SIEM detection does not carry. The new command always keeps the product:'ngsiem' clause and ANDs any filter to it, takes the same created_from watermark, and ships its own mapper and a CrowdStrike NG-SIEM Detection incident type. The mapper is a first cut on the fields every unified alert carries (identity, name, description, severity, status, timestamps, MITRE); hosts, users and the source log will follow from a real payload. | 1.5.0 — resolve_detection sets the verdict. Falcon has no verdict field on an alert: the console's Resolution column is read from the tags true_positive, false_positive and ignored, and the status only takes new, in_progress, reopened and closed — so no value of the old status box could record a verdict. The new verdict input adds the tag, after removing the other two so an alert never carries two resolutions, and status and verdict are now drop-downs. 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." +changelog: "1.7.0 — device_count can count a sensor fleet the way the agent reports need it. Four version bounds, agent_version_lt, agent_version_lte, agent_version_gt and agent_version_gte, count the sensors below or above a build, per platform beside platform_name: FQL compares agent_version as text, where 7.9 sorts above 7.18, so they are not sent but compared by the script, which scrolls the device IDs the other filters keep, reads their versions 1000 at a time and compares number by number. A device whose version cannot be read is left out of the count and reported under unreadable_version; scanned gives how many were read, and a bound that is not a version fails the block. The first_seen and last_seen bounds also take a duration relative to now (24h, 7d, 30m), so a scheduled playbook can count the sensors seen in the last day without computing a date. 1.6.3 — resolve_detection fails when Falcon changed nothing. The alerts PATCH answers 200 to an update it refused, with the reasons in errors and meta.writes.resources_affected at 0, and the command passed that on as a success: an incident could be closed in Riposte and stay open in the console. It now exits in error with Falcon's errors and the ids it sent (the PATCH takes composite ids, :). A call with nothing to change is refused before reaching Falcon. The command applies to every unified alert, NG-SIEM and third-party detections included. | 1.6.2 — search_ngsiem_detections reads products thirdparty and ngsiem by default, set by a new products input. On a real tenant the NG-SIEM detections of the Unified Detections page — here FortiGate IPS hits — are filed under product thirdparty, and product ngsiem matched nothing, so 1.6.0 returned an empty list. Its mapper is rewritten from those payloads: source product and vendor, the host Falcon matched the detection to, source and target IPs and ports, protocol, the vendor signature id as the analytic uid, the Falcon priority as risk score, the console link, and the source event time as OCSF time. Third-party detections carry no description, so the incident's is built from the source product, the signature and the connection. | 1.6.1 — search_detections and search_ngsiem_detections return a query block next to resources: the FQL actually sent, the total Falcon counts for it and any errors the query carried. A search that matched nothing used to come back as a bare empty list, which could not be told apart from a wrong region, a filter Falcon read differently or an API client that sees no alerts. | 1.6.0 — New search_ngsiem_detections command for Next-Gen SIEM detections. Falcon files the detections of NG-SIEM correlation rules in the same unified Alerts API as the EDR, under product 'ngsiem', so search_detections' default epp/ldt scope never returned them, and its mapper reads device, process and file blocks an NG-SIEM detection does not carry. The new command always keeps the product:'ngsiem' clause and ANDs any filter to it, takes the same created_from watermark, and ships its own mapper and a CrowdStrike NG-SIEM Detection incident type. The mapper is a first cut on the fields every unified alert carries (identity, name, description, severity, status, timestamps, MITRE); hosts, users and the source log will follow from a real payload. | 1.5.0 — resolve_detection sets the verdict. Falcon has no verdict field on an alert: the console's Resolution column is read from the tags true_positive, false_positive and ignored, and the status only takes new, in_progress, reopened and closed — so no value of the old status box could record a verdict. The new verdict input adds the tag, after removing the other two so an alert never carries two resolutions, and status and verdict are now drop-downs. 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 # Per-instance configuration. Scripts obtain an OAuth2 bearer token from @@ -70,10 +70,14 @@ commands: 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." } + first_seen_after: { type: string, description: "Lower bound on first_seen, UTC (e.g. 2026-08-21T00:00:00Z) — devices enrolled since that instant. Also relative to now: 24h, 7d, 30m." } + first_seen_before: { type: string, description: "Upper bound on first_seen, UTC. Also relative to now: 24h, 7d, 30m." } + last_seen_after: { type: string, description: "Lower bound on last_seen, UTC — devices seen since that instant. Also relative to now: 24h, 7d, 30m." } + last_seen_before: { type: string, description: "Upper bound on last_seen, UTC — devices silent since that instant. Also relative to now: 24h, 7d, 30m." } + agent_version_lt: { type: string, description: "Only sensors on a version strictly older than this one, e.g. 7.18.18803 — the fleet still to be upgraded to it. Compared by Riposte, number by number, over every device the other filters keep" } + agent_version_lte: { type: string, description: "Only sensors on this version or older, e.g. 7.18.18803" } + agent_version_gt: { type: string, description: "Only sensors on a version strictly newer than this one, e.g. 7.18.18803" } + agent_version_gte: { type: string, description: "Only sensors on this version or newer, e.g. 7.18.18803" } filter: { type: string, description: "Any other criterion, as raw FQL, ANDed with the rest (e.g. kernel_version:'5.15*')." } required: [] outputs_schema: { properties: {} } diff --git a/integrations/crowdstrike/scripts/device_count.py b/integrations/crowdstrike/scripts/device_count.py index 4a26fba..0b64abe 100644 --- a/integrations/crowdstrike/scripts/device_count.py +++ b/integrations/crowdstrike/scripts/device_count.py @@ -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