← Files CrowdStrike Falcon FusionARCHIVED FILE

skills/execution/scripts/trigger_workflow.py

11.3 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

"""
Trigger a CrowdStrike Fusion workflow and optionally wait for results.

Executes an on-demand workflow by definition ID, passing parameters either as a
JSON string (--params) or via interactive prompts derived from the workflow's
parameter schema. With --wait, polls until the execution reaches a terminal
state.

Usage:
    python trigger_workflow.py --id <def_id> --params '{"device_id":"abc123"}'
    python trigger_workflow.py --id <def_id>                     # Interactive parameter prompt
    python trigger_workflow.py --id <def_id> --params '{}' --wait --timeout 120
    python trigger_workflow.py --id <def_id> --autofill --email me@example.com --wait
"""

import argparse
import json
import time
import sys
import os

# Add the shared common/scripts directory (two levels up) to sys.path so the
# auth module resolves no matter where this script is launched from.
sys.path.insert(
    0,
    os.path.join(
        os.path.dirname(os.path.realpath(__file__)),
        "..", "..", "..", "common", "scripts",
    ),
)
import _bootstrap  # pylint: disable=wrong-import-position
_bootstrap.ensure_deps(__file__)  # re-exec via managed venv if deps are missing
from auth import get_client  # pylint: disable=wrong-import-position

# fetch_results and TERMINAL_STATUSES live alongside this script.
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
from get_execution_results import fetch_results, TERMINAL_STATUSES  # pylint: disable=wrong-import-position

# Fix Windows console encoding
sys.stdout.reconfigure(encoding="utf-8", errors="replace")


def get_workflow_params_schema(definition_id):
    """Fetch the parameter schema for a workflow definition."""
    try:
        client = get_client()
        resp = client.search_definitions(filter=f"id:'{definition_id}'")
        body = resp["body"]
        resources = body.get("resources", [])
        if not resources:
            return None
        trigger = resources[0].get("trigger", {})
        return trigger.get("parameters", {}).get("properties", {})
    except (ConnectionError, RuntimeError, OSError):
        return None


def get_trigger_parameters(definition_id):
    """
    Fetch the full On-demand parameter schema (properties AND required list).

    Returns a (properties, required) tuple. Unlike get_workflow_params_schema,
    which returns only the properties for interactive prompting, this exposes the
    JSON-schema `required` array so callers can autofill the mandatory inputs a
    workflow will reject an execution for if omitted.
    """
    try:
        client = get_client()
        resp = client.search_definitions(filter=f"id:'{definition_id}'")
        resources = resp["body"].get("resources", [])
        if not resources:
            return {}, []
        params = resources[0].get("trigger", {}).get("parameters", {})
        return params.get("properties", {}) or {}, params.get("required", []) or []
    except (ConnectionError, RuntimeError, OSError):
        return {}, []


def heuristic_value(field_name, field_schema):
    """
    Derive a schema-valid placeholder value for a required parameter.

    Used by autofill_params to satisfy a workflow's required On-demand inputs
    when no explicit override is supplied. The goal is a value that (a) matches
    the declared JSON-schema type so input validation passes, and (b) is
    semantically plausible for common indicator field names so an enrichment
    workflow has something real to act on. Explicit overrides always win over
    these guesses — see autofill_params.
    """
    ftype = (field_schema or {}).get("type", "string")

    # Non-string types get a minimal schema-valid value regardless of name.
    non_string = {"integer": 1, "number": 1, "boolean": False, "array": [], "object": {}}
    if ftype in non_string:
        return non_string[ftype]

    # String-typed: guess by field name so indicator enrichment gets real input.
    name = field_name.lower()
    # Ordered (substring, value) pairs; first match wins.
    guesses = [
        ("email", "verify@example.com"),
        ("ip", "185.220.101.1"),   # a Tor exit node — returns a real VirusTotal verdict
        ("domain", "example.com"),
        ("url", "https://example.com"),
        ("sha256", "44d88612fea8a8f36de82e1278abb02f"),  # EICAR test-file MD5
        ("hash", "44d88612fea8a8f36de82e1278abb02f"),
    ]
    for needle, value in guesses:
        if needle in name:
            return value
    return "test"


def autofill_params(params, properties, required, overrides=None):
    """
    Fill any required parameters missing from `params`.

    Precedence for each missing required field: an explicit override (from the
    caller) first, then a name/type heuristic. Fields already present in `params`
    are left untouched. Returns a new merged dict; does not mutate the input.
    """
    merged = dict(params or {})
    overrides = overrides or {}
    for name in required:
        if name in merged:
            continue
        if name in overrides:
            merged[name] = overrides[name]
        else:
            merged[name] = heuristic_value(name, (properties or {}).get(name, {}))
    return merged


def prompt_for_params(schema):
    """Interactively prompt the user for each parameter."""
    params = {}
    if not schema:
        print("  No parameter schema found. Enter JSON manually:")
        raw = input("  > ")
        return json.loads(raw) if raw.strip() else {}

    print("\n  Enter parameter values (leave blank for optional fields):\n")
    for field_name, field_schema in schema.items():
        title = field_schema.get("title", field_name)
        ftype = field_schema.get("type", "string")
        desc = field_schema.get("description", "")
        prompt_text = f"  {title} ({ftype})"
        if desc:
            prompt_text += f" — {desc}"
        prompt_text += ": "

        value = input(prompt_text)
        if not value:
            continue

        # Type coercion
        if ftype == "integer":
            value = int(value)
        elif ftype == "boolean":
            value = value.lower() in ("true", "1", "yes")
        elif ftype == "array":
            try:
                value = json.loads(value)
            except json.JSONDecodeError:
                # Treat as comma-separated strings
                value = [v.strip() for v in value.split(",")]
        elif ftype == "object":
            value = json.loads(value)

        params[field_name] = value

    return params


def execute_workflow(definition_id, params, depth=1):
    """
    Execute a workflow. Returns (success, execution_id, response_body).
    """
    try:
        client = get_client()
        resp = client.execute(
            definition_id=[definition_id],
            depth=depth,
            body=params,
        )
        body = resp["body"]
        errors = body.get("errors", [])
        if errors:
            msg = "; ".join(e.get("message", str(e)) for e in errors)
            return False, None, msg

        resources = body.get("resources", [])
        # The execute endpoint returns resources as a list of bare execution-ID
        # strings, not objects. Handle both shapes defensively.
        exec_id = None
        if resources:
            first = resources[0]
            exec_id = first if isinstance(first, str) else first.get("id")
        return True, exec_id, body
    except (ConnectionError, RuntimeError, OSError) as exc:
        return False, None, str(exc)


def poll_results(execution_id, timeout=120, interval=5):
    """
    Poll for execution results until a terminal state or timeout.

    Delegates the single-fetch + envelope parsing to fetch_results so this
    script and get_execution_results.py stay in sync on the API response shape.
    Returns the result dict or None on timeout.
    """
    start = time.time()
    print(f"\n  Polling for results (timeout: {timeout}s)...")
    while time.time() - start < timeout:
        ok, msg, result = fetch_results(execution_id)
        if ok and result:
            status = result.get("status", "")
            if status.lower() in TERMINAL_STATUSES:
                return result
            print(f"    Status: {status} ({int(time.time() - start)}s elapsed)")
        else:
            print(f"    Poll error: {msg}")
        time.sleep(interval)

    print(f"  Timeout after {timeout}s — execution may still be running.")
    return None


def main():
    """CLI entry point for workflow execution."""
    parser = argparse.ArgumentParser(description="Trigger a Fusion workflow")
    parser.add_argument("--id", required=True, metavar="DEF_ID", help="Workflow definition ID")
    parser.add_argument("--params", metavar="JSON", help="Execution parameters as JSON string")
    parser.add_argument("--autofill", action="store_true",
                        help="Fill any required trigger params missing from --params "
                             "using name/type heuristics (non-interactive verification)")
    parser.add_argument("--email", metavar="ADDR",
                        help="Override value for email-type required params when --autofill is set")
    parser.add_argument("--wait", action="store_true", help="Poll for execution results")
    parser.add_argument("--timeout", type=int, default=120, help="Poll timeout in seconds (default: 120)")
    parser.add_argument("--json", action="store_true", help="Machine-readable JSON output")
    args = parser.parse_args()

    # Get parameters
    if args.params:
        params = json.loads(args.params)
    elif args.autofill:
        # --autofill is non-interactive: start empty and let autofill (below)
        # populate the required inputs. Do NOT drop into the interactive prompt.
        params = {}
    else:
        # Interactive mode
        schema = get_workflow_params_schema(args.id)
        params = prompt_for_params(schema)

    # Autofill required params the caller did not supply. Used by the verify
    # harness so an On-demand workflow with required inputs (e.g. ip,
    # notify_email) is not rejected at input validation before any action runs.
    if args.autofill:
        properties, required = get_trigger_parameters(args.id)
        overrides = {}
        if args.email:
            overrides = {n: args.email for n in required
                         if "email" in n.lower()}
        params = autofill_params(params, properties, required, overrides)

    print(f"\n  Executing workflow {args.id}")
    print(f"  Parameters: {json.dumps(params, indent=2)}")

    ok, exec_id, resp = execute_workflow(args.id, params)

    if not ok:
        print(f"\n  Execution FAILED: {resp}")
        sys.exit(1)

    print(f"  Execution ID: {exec_id}")

    if args.json and not args.wait:
        print(json.dumps(resp, indent=2))
        return

    # Poll for results
    if args.wait and exec_id:
        result = poll_results(exec_id, timeout=args.timeout)
        if result:
            if args.json:
                print(json.dumps(result, indent=2))
            else:
                status = result.get("status", "?")
                print(f"\n  Execution {status}")
                output = result.get("output", {})
                if output:
                    print(f"  Output:\n{json.dumps(output, indent=4)}")
        else:
            print("  No results returned within timeout.")
            sys.exit(1)
    elif not args.wait:
        print("\n  Execution submitted. Use --wait to poll for results.")


if __name__ == "__main__":
    main()

SHA-256: 7325019566624c4374813a7d61531a3ab1b305cdced91829bc22f7ca436047a0