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 |
|
|---|---|---|
Server |
Trigger kind, module, step type, connector, tag, collection |
|
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.
Server-side search¶
find() maps onto FortiSOAR’s
deep-relationship filter params. Every argument is optional and all are ANDed.
from pyfsr import FortiSOAR
client = FortiSOAR("https://your-fsr", token="...")
# What fires when an asset is created?
client.playbooks.find(trigger_type="on_create", trigger_module="assets")
# Playbooks that both set a variable and call FortiGate
client.playbooks.find(step_type="set_variable", uses_connector="fortigate")
# Everything on a collection, active only
client.playbooks.find(collection="<uuid>", active=True)
# By name (exact) or substring
client.playbooks.find(name="Block IP")
client.playbooks.find(name_contains="block")
# By tag, private/public, or remote-executable
client.playbooks.find(tag="auto-response")
client.playbooks.find(private=True)
client.playbooks.find(remote_executable=True)
The full parameter set: name, name_contains, collection, tag, active,
private, trigger_type, trigger_module, step_type, uses_connector,
uses_operation, route, references, remote_executable, single_record.
Triggers¶
trigger_type takes a friendly alias from TRIGGER_TYPE_NAMES – manual,
on_create, on_update, on_delete, referenced (or child), api_endpoint
(or api) – or a raw cybersponse.* name. The trigger kind is a step type
on the start step, so this filters exactly.
trigger_module scopes it to the module the trigger is bound to:
client.playbooks.find(trigger_type="on_update", trigger_module="alerts")
The bound module lives in the trigger step’s own arguments.resources, which is
a different JSON column from steps.arguments – so trigger_module composes
freely with uses_connector and friends. The value is matched quoted, so
assets does not also match asset_change_activities.
For manual triggers specifically,
manual_on_module() additionally returns
each playbook’s Execute-menu button label and every tied module:
for pb in client.playbooks.manual_on_module("alerts"):
print(pb["label"], "-", pb["name"], "modules:", pb["resources"])
# VirusTotal: Get IP Reputation - Get IP Reputation modules: ['alerts']
# button text: pb["execute_button_text"]
Step content¶
step_type accepts a friendly alias from STEP_TYPE_NAMES (connector,
set_variable, decision, code_snippet, manual_input, approval,
find_record / find_records, create_record / insert_record,
update_record, delay / wait, workflow_reference / reference /
reference_playbook, send_mail / email, utility / no_op,
set_api_keys, …) or a raw engine name.
Warning
uses_connector, uses_operation, route, and references all match a
substring of the whole steps.arguments JSON, so they over-match. Measured
on one appliance, uses_connector="fortigate" returned 85 playbooks where only
58 had a step actually invoking a FortiGate connector – a 32% false-positive
rate, from things like HTML in a description, sample event data
("eventType": "FortiGate-traffic-forward"), and text inside a Jinja comment.
They also share one JSON column, so passing more than one per call raises
ValueError. When precision matters, filter server-side then refine with
match() – see below.
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.