Finding Playbooks

Answering “what runs when an asset is created?” or “which playbooks block an IP on FortiGate?” against an appliance carrying a couple of thousand playbooks.

Two layers do this, and picking the right one is most of the work:

Where it runs

Use it for

find()

Server

Trigger kind, module, step type, connector, tag, collection

pyfsr.playbook_match

Client

Same-step precision, counts, parent/child joins

Tip

Reach for find() first. Pulling every playbook with $relationships=true costs about 29 MB and 100 s on a 1.8k-playbook appliance; the equivalent find() call answers in well under a second. Only fall through to playbook_match for the questions the filter language genuinely cannot express – and even then, pass a prefilter so the server still does the narrowing.

Client-side structural matching

pyfsr.playbook_match parses each playbook into a ParsedPlaybook and evaluates composable predicates. Use it for the four things find() cannot express:

  • Same-step precision – “one step that is both FortiGate and block_ip”. find(uses_connector=..., uses_operation=...) is not even allowed in one call, and would match the two facets landing on different steps.

  • Quantities – “exactly two set-variable steps”.

  • Parent/child joins – “a manual playbook whose referenced child blocks an IP”.

  • Trigger metadata – button labels and bound resources.

Always pass a prefilter: it is a find()-style param dict applied server-side before anything is fetched.

from pyfsr.playbook_match import step, has, count, all_of

# Precise: connector AND operation on the SAME step
client.playbooks.match(
    has(step(connector="fortigate", operation="block_ip")),
    prefilter={"steps.arguments$like": "%fortigate%"},
)

# Exactly 2 set-variable steps and at least 1 code snippet
client.playbooks.match(
    all_of(count(step(step_type="set_variable"), n=2),
           count(step(step_type="code_snippet"), min=1)),
)

Predicates compose with all_of, any_of, and none_of. Note that step(connector="fortigate") matches case-insensitively as a substring, so it finds the installed package name fortigate-firewall.

parse_playbook reduces a raw /api/3/workflows record (fetched with relationships so steps are inlined) into a ParsedPlaybook – the shape predicates evaluate against. It is a pure function, so you can inspect the reduction offline:

>>> from pyfsr.playbook_match import parse_playbook, step, has, count, trigger
>>> wf = {
...     "name": "Block IP on FortiGate",
...     "uuid": "abc-123",
...     "steps": [
...         {"name": "Start", "stepType": {"name": "cybersponse.action"}, "arguments": {}},
...         {"name": "Block", "stepType": {"name": "ConnectorStep"},
...          "arguments": {"connector": "fortigate-firewall", "operation": "block_ip"}},
...         {"name": "Set var", "stepType": {"name": "SetVariable"}, "arguments": {}},
...         {"name": "Set var 2", "stepType": {"name": "SetVariable"}, "arguments": {}},
...     ],
... }
>>> parsed = parse_playbook(wf)
>>> parsed.name, parsed.uuid, parsed.trigger_type
('Block IP on FortiGate', 'abc-123', 'manual')
>>> [(s.name, s.connector, s.operation) for s in parsed.steps]
[('Start', None, None), ('Block', 'fortigate-firewall', 'block_ip'),
 ('Set var', None, None), ('Set var 2', None, None)]

Predicates evaluate against that parsed shape – same-step precision and exact counts, the two things find() cannot express:

>>> has(step(connector="fortigate", operation="block_ip"))(parsed)  # same step
True
>>> count(step(step_type="set_variable"), n=2)(parsed)               # exactly 2
True
>>> trigger("manual")(parsed)                                        # trigger kind
True

Parent/child joins

match_across() finds parents whose referenced child satisfies a second predicate – a reference step targets its child by IRI, not by name:

from pyfsr.playbook_match import trigger

client.playbooks.match_across(
    trigger("manual"),
    has(step(connector="fortigate", operation="block_ip")),
)

Recipes

# Everything bound to a module, by trigger kind
{kind: len(client.playbooks.find(trigger_type=kind, trigger_module="alerts"))
 for kind in ("on_create", "on_update", "manual")}

# Disabled playbooks still wired to a trigger
client.playbooks.find(trigger_type="on_create", active=False)

# Which playbooks reference this one as a child
client.playbooks.find(references="> get outbound traffic report")

# API-triggered playbooks and their routes
client.playbooks.find(trigger_type="api_endpoint")

See also

Playbook Authoring & Deployment for creating playbooks, and Querying for the underlying query builder used by prefilter.