diff --git a/integrations/crowdstrike/manifest.yaml b/integrations/crowdstrike/manifest.yaml index c22afec..9e08586 100644 --- a/integrations/crowdstrike/manifest.yaml +++ b/integrations/crowdstrike/manifest.yaml @@ -1,8 +1,8 @@ id: crowdstrike 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." -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 # Per-instance configuration. Scripts obtain an OAuth2 bearer token from @@ -48,6 +48,36 @@ commands: required: [] 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 name: crowdstrike-get-device-details description: "Retrieve full details for one or more devices by ID." diff --git a/integrations/crowdstrike/scripts/device_count.py b/integrations/crowdstrike/scripts/device_count.py new file mode 100644 index 0000000..4a26fba --- /dev/null +++ b/integrations/crowdstrike/scripts/device_count.py @@ -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)