Tag: fortianalyzer

  • FortiGate onboard automation: Stitches, auto-scripts, batch mode

    A FortiGate comes with several tools built in to help us with automation and responding to events. In this post, I want to explore these options a bit more and show what you can do with them.

    So, dear reader, let’s see what we can do with these tools!

    The setup:

    • 2x FortiGate 70G running 7.6.6
    • A FortiAnalyzer VM running 7.6.6

    Automation stitches

    The biggest, most powerful, and most dynamic option a FortiGate offers are automation stitches. You combine actions, which do something, and triggers, which respond to something, in a stitch, where you handle the logic and the flow, like sequential and parallel execution of actions and delays.

    Before I get more into this part, please check out Yuri Slobodyanyuk’s blog post on automation stitches because he already did a lot of work on this topic.

    Automation actions

    Actions in stitches are the things actually being done. You can send notifications to Slack, Teams, send mails, trigger functions in cloud environments, execute CLI scripts, or execute webhooks.

    There isn’t much to say about actions because they are rather self-explanatory, but one thing I want to mention is that CLI scripts are limited to 1023 characters, and you can use some variables in them.

    FortiGate automation action help

    Automation triggers

    Triggers are the second thing we need to care about, because if we don’t know what event interests us, we won’t do anything in response.

    FortiOS comes with some default triggers, like Configuration Change, HA Failover, and Conserve Mode, but I will focus on FortiOS Event Log, FortiAnalyzer Event Handler, and Incoming Webhook, because they offer the most flexibility.

    FortiOS Event Log triggers

    You cannot trigger on every event log, but the ones we can choose from are vast and knowing how to work with them is important.

    Let’s take a look at two practical examples.

    Example: Link monitor events

    First, assume that you are monitoring a server using a link monitor and you need to respond to it being down, like enabling a static route.

    The Link monitor status event produces the following log entry:

    date=2026-05-02 time=16:19:29 eventtime=1777738768978397230 logid="0100022922" type="event" subtype="system" level="notice" vd="root" logdesc="Link monitor status" name="WIN-AD" msg="Link monitor state is changed from 0 to 1, please check if this triggers HA failover."
    Link monitor log details

    In this case, it is a down event (changed from 0 to 1). There is also a Link monitor status warning event, which makes this particular scenario clearer and easier to work with, but I won’t focus on that for a reason I will get to later.

    In the log details, we can see the message (msg) and the link monitor name (name). This information is enough to build an automation trigger specific to this link monitor using Field filters.

    Link monitor down trigger
    config system automation-trigger
    	edit "LINK-MONITOR-DOWN"
            set event-type event-log
            set logid 22922
            config fields
                edit 1
                    set name "name"
                    set value "WIN-AD"
                next
                edit 2
                    set name "msg"
                    set value "*from 0 to 1*"
                next
            end
        next
    end

    The wildcard symbol * does the magic here, because it allows us to focus on only what we need in the message and the name field covers the specific link monitor.

    Use the trigger in a stitch and add whatever action you need. You can also build the reverse trigger, i.e. a link monitor coming alive, using the same event and name, but the message *from 1 to 0*.

    The reason I am not using the Link monitor status warning event here is that a dead link monitor uses the warning event (ID 0100022932) in addition to the Link monitor status event (ID 0100022922). A link monitor coming alive uses only Link monitor status (ID 0100022922). I don’t want to use two different IDs for one process (link monitor alive and dead), but you can choose to do that.

    Here are all the log messages for these scenarios:

    date=2026-05-02 time=16:19:41 eventtime=1777738780479592552 logid="0100022922" type="event" subtype="system" level="notice" vd="root" logdesc="Link monitor status" name="WIN-AD" msg="Link monitor state is changed from 1 to 0, please check if this triggers HA failover."
    date=2026-05-02 time=16:19:41 eventtime=1777738780479544752 logid="0100022922" type="event" subtype="system" level="notice" vd="root" logdesc="Link monitor status" name="WIN-AD" interface="undefined" probeproto="ping" msg="Link Monitor changed state from dead to alive, protocol: ping."
    date=2026-05-02 time=16:19:29 eventtime=1777738768978397230 logid="0100022922" type="event" subtype="system" level="notice" vd="root" logdesc="Link monitor status" name="WIN-AD" msg="Link monitor state is changed from 0 to 1, please check if this triggers HA failover."
    date=2026-05-02 time=16:19:29 eventtime=1777738768978357571 logid="0100022932" type="event" subtype="system" level="warning" vd="root" logdesc="Link monitor status warning" name="WIN-AD" interface="undefined" probeproto="ping" msg="Link Monitor changed state from alive to dead, protocol: ping."
    Link monitor log entries

    Example: BGP neighborship events

    The second example is around BGP neighborships. Assume that you want to create a trigger for a specific BGP neighbor going down and enable a static route if such an event happens. If we look at the logs for a BGP neighborship down event, we see the following:

    date=2026-05-02 time=16:23:18 eventtime=1777738998290854913 logid="0103020300" type="event" subtype="router" level="warning" vd="root" logdesc="BGP neighbor status changed" msg="BGP: %BGP-5-ADJCHANGE: VRF 0 neighbor 198.51.100.1 Down BGP Notification FSM-ERR"
    BGP down log detail

    In this case, we only have the message to work with, so a trigger to respond to such an event can look like this:

    BGP neighbor down trigger
    config system automation-trigger
        edit "BGP-DOWN"
            set event-type event-log
            set logid 20300
            config fields
                edit 1
                    set name "msg"
                    set value "*neighbor 198.51.100.1 Down*"
                next
            end
        next
    end

    A full debug of the event being generated and the automation stitch firing can be seen here (in my case, I am simply disabling port2):

    BGP down event debug

    autod(pid:1622) log packet: total sz:470 data sz:230 fld_num:8
    
    autod(pid:1622) log datetime: 2026-05-01 11:56:27
    
    autod(pid:1622) log header: logid:20300 vfid:0 sever:4 cat:1 subcat:3 key:0 flags:0404 reqlen:110 timestamp:1777636586845355006
    fields:
                    id:10 name:(9)eventtime value:(19)1777636586845355006
                    id:2 name:(5)logid value:(10)0103020300
                    id:3 name:(4)type value:(5)event
                    id:4 name:(7)subtype value:(6)router
                    id:5 name:(5)level value:(7)warning
                    id:6 name:(2)vd value:(4)root
                    id:38 name:(7)logdesc value:(27)BGP neighbor status changed
                    id:24 name:(3)msg value:(80)BGP: %BGP-5-ADJCHANGE: VRF 0 neighbor 198.51.100.1 Down BGP Notification FSM-ERR
    pid:1622-__subscr_close_cur_pkg()-141: close package size:1536 logs:2
    __action_cli_script_open()-169: cli script action:PORT2-DOWN is called. svc ctx:0x559d57c000
    accprof:super_admin script:
    config system interface
    edit port2
    set status down
    next
    end
    
    __read_cli_script_result()-117: cli script:
    autod.10
     output:
    
    ========== #1, 2026-05-01 11:56:27 ==========
    70G-02  config system interface
    70G-02 (interface)  edit port2
    70G-02 (port2)  set status down
    70G-02 (port2)  next
    70G-02 (interface)  end
    
    ======= end of #1, 2026-05-01 11:56:27 ======
    
    
    __action_cli_script_close()-219: cli script action is done. script:
    config system interface
    edit port2
    set status down
    next
    end
    output:
    
    ========== #1, 2026-05-01 11:56:27 ==========
    70G-02  config system interface
    70G-02 (interface)  edit port2
    70G-02 (port2)  set status down
    70G-02 (port2)  next
    70G-02 (interface)  end
    
    ======= end of #1, 2026-05-01 11:56:27 ======
    
    
    pid:1622-__handle_msg()-428: Subscriber:4 received package. pubid:0 pkgid:1206 pkg_index:0
    pid:1622-__handle_pkg_logs()-370: Subscriber:4 processing package size:2253 logs:3 pickup:3
    
    autod(pid:1622) log packet: total sz:1194 data sz:252 fld_num:12
    
    autod(pid:1622) log datetime: 2026-05-01 11:56:28
    
    autod(pid:1622) log header: logid:46600 vfid:0 sever:5 cat:1 subcat:0 key:0 flags:0484 reqlen:131 timestamp:1777636587533074284
    fields:
                    id:10 name:(9)eventtime value:(19)1777636587533074284
                    id:2 name:(5)logid value:(10)0100046600
                    id:3 name:(4)type value:(5)event
                    id:4 name:(7)subtype value:(6)system
                    id:5 name:(5)level value:(6)notice
                    id:6 name:(2)vd value:(4)root
                    id:38 name:(7)logdesc value:(27)Automation stitch triggered
                    id:321 name:(6)stitch value:(8)BGP-DOWN
                    id:322 name:(7)trigger value:(8)BGP-DOWN
                    id:377 name:(12)stitchaction value:(10)PORT2-DOWN
                    id:226 name:(4)from value:(3)log
                    id:24 name:(3)msg value:(29)stitch:BGP-DOWN is triggered.
    
    autod(pid:1622) log packet: total sz:626 data sz:298 fld_num:14
    
    autod(pid:1622) log datetime: 2026-05-01 11:56:28
    
    autod(pid:1622) log header: logid:44547 vfid:0 sever:6 cat:1 subcat:0 key:0 flags:0404 reqlen:180 timestamp:1777636587767735898
    fields:
                    id:10 name:(9)eventtime value:(19)1777636587767735898
                    id:2 name:(5)logid value:(10)0100044547
                    id:3 name:(4)type value:(5)event
                    id:4 name:(7)subtype value:(6)system
                    id:5 name:(5)level value:(11)information
                    id:6 name:(2)vd value:(4)root
                    id:38 name:(7)logdesc value:(27)Object attribute configured
                    id:57 name:(2)ui value:(11)auto-script
                    id:12 name:(6)action value:(4)Edit
                    id:59 name:(6)cfgtid value:(9)600113152
                    id:62 name:(7)cfgpath value:(16)system.interface
                    id:63 name:(6)cfgobj value:(5)port2
                    id:64 name:(7)cfgattr value:(16)status[up->down]
                    id:24 name:(3)msg value:(27)Edit system.interface port2
    
    autod(pid:1622) log packet: total sz:433 data sz:193 fld_num:8
    
    autod(pid:1622) log datetime: 2026-05-01 11:56:28
    
    autod(pid:1622) log header: logid:32549 vfid:0 sever:6 cat:1 subcat:0 key:0 flags:0404 reqlen:67 timestamp:1777636587778632118
    fields:
                    id:10 name:(9)eventtime value:(19)1777636587778632118
                    id:2 name:(5)logid value:(10)0100032549
                    id:3 name:(4)type value:(5)event
                    id:4 name:(7)subtype value:(6)system
                    id:5 name:(5)level value:(11)information
                    id:6 name:(2)vd value:(4)root
                    id:38 name:(7)logdesc value:(29)Autoscript stop automatically
                    id:24 name:(3)msg value:(37)script autod.10 stopped automatically
    

    You can also create a reverse trigger and use the BGP neighbor up message.

    Here are all the log messages for these scenarios:

    date=2026-05-02 time=16:23:47 eventtime=1777739026657108610 logid="0103020300" type="event" subtype="router" level="warning" vd="root" logdesc="BGP neighbor status changed" msg="BGP: %BGP-5-ADJCHANGE: VRF 0 neighbor 198.51.100.1 Up "
    date=2026-05-02 time=16:23:47 eventtime=1777739026657075206 logid="0103020300" type="event" subtype="router" level="warning" vd="root" logdesc="BGP neighbor status changed" msg="BGP: %BGP-5-ADJCHANGE: VRF 0 neighbor 198.51.100.1 Down Peer being deleted"
    date=2026-05-02 time=16:23:47 eventtime=1777739026657040239 logid="0103020304" type="event" subtype="router" level="warning" vd="root" logdesc="Routing log warning" msg="BGP: %BGP-3-NOTIFICATION: sending to 198.51.100.1 6/0 (CeaseUnspecified Error Subcode) 0 data-bytes []"
    date=2026-05-02 time=16:23:18 eventtime=1777738998290854913 logid="0103020300" type="event" subtype="router" level="warning" vd="root" logdesc="BGP neighbor status changed" msg="BGP: %BGP-5-ADJCHANGE: VRF 0 neighbor 198.51.100.1 Down BGP Notification FSM-ERR"
    date=2026-05-02 time=16:23:18 eventtime=1777738998290842322 logid="0103020304" type="event" subtype="router" level="warning" vd="root" logdesc="Routing log warning" msg="BGP: %BGP-3-NOTIFICATION: received from 198.51.100.1 4/0 (Hold Timer Expired/Unspecified Error Subcode) 0 data-bytes []"
    BGP Up/Down log entries
    config system automation-trigger
        edit "BGP-UP"
            set event-type event-log
            set logid 20300
            config fields
                edit 1
                    set name "msg"
                    set value "*neighbor 198.51.100.1 Up*"
                next
            end
        next
    end

    Incoming Webhook

    This option is powerful, but boring. You create the trigger, and you get a URL to send HTTP POST requests to. Moving on… 

    An automation debugging aside

    If you ever need to debug automation stitches, you do that using the autod application and a triggered stitch looks like this:

    Automation stitch executed

    __action_cli_script_open()-169: cli script action:PORT2-DOWN is called. svc ctx:0x55a8894cf0
    accprof:super_admin script:
    config system interface
    edit port2
    set status down
    next
    end
    
    __read_cli_script_result()-117: cli script:
    autod.0
     output:
    
    ========== #1, 2026-05-02 17:28:27 ==========
    70G-02  config system interface
    70G-02 (interface)  edit port2
    70G-02 (port2)  set status down
    70G-02 (port2)  next
    70G-02 (interface)  end
    
    ======= end of #1, 2026-05-02 17:28:27 ======
    
    
    __action_cli_script_close()-219: cli script action is done. script:
    config system interface
    edit port2
    set status down
    next
    end
    output:
    
    ========== #1, 2026-05-02 17:28:27 ==========
    70G-02  config system interface
    70G-02 (interface)  edit port2
    70G-02 (port2)  set status down
    70G-02 (port2)  next
    70G-02 (interface)  end
    
    ======= end of #1, 2026-05-02 17:28:27 ======

    Some more options regarding debugging are in Yuri’s blog post (again, check it out), like testing, statistics, etc.

    One thing I think is interesting is the diagnose test application autod 1 option. In Yuri’s blog, the description is “Enable automation stitches logging.” but the description of the option is actually “Enable/disable log dumping”, and this really does mean that. It dumps all logs straight to the CLI.

    Turn it on, and you get a live feed of every log being created, and since not only the examples of link monitors or BGP events create log entries, but also configuration changes, we can get some nice information quickly. Here is the output of a new firewall policy being created, as well as the regular log entry, for example:

    autod(pid:1622) log header: logid:44547 vfid:0 sever:6 cat:1 subcat:0 key:0 flags:0404 reqlen:359 timestamp:1777637024413047347
    fields:
                    id:10 name:(9)eventtime value:(19)1777637024413047347
                    id:2 name:(5)logid value:(10)0100044547
                    id:3 name:(4)type value:(5)event
                    id:4 name:(7)subtype value:(6)system
                    id:5 name:(5)level value:(11)information
                    id:6 name:(2)vd value:(4)root
                    id:38 name:(7)logdesc value:(27)Object attribute configured
                    id:22 name:(4)user value:(5)admin
                    id:57 name:(2)ui value:(20)https(192.168.1.101)
                    id:12 name:(6)action value:(3)Add
                    id:59 name:(6)cfgtid value:(8)96927855
                    id:58 name:(4)uuid value:(36)cc0608f6-4555-51f1-0501-0fe328bf84a4
                    id:62 name:(7)cfgpath value:(15)firewall.policy
                    id:63 name:(6)cfgobj value:(1)6
                    id:64 name:(7)cfgattr value:(150)name[DEBUG-LOG-POLICY]srcintf[port1]dstintf[SDWAN-OUTSIDE]action[accept]srcaddr[all]dstaddr[all]schedule[always]service[ALL]logtraffic[all]nat[enable]
                    id:24 name:(3)msg value:(21)Add firewall.policy 6
    New policy creation log

    Cool, right?

    FortiAnalyzer Event Handlers

    The FortiOS Event Log triggers are nice, but they aren’t flexible. If there is no log for the scenario you need, if you need something more specific, or if you need some logic, they hit their limit. FortiAnalyzer takes care of this. Now, this is not strictly “onboard” because you do need another product, but I would be remiss not to mention it.

    If you integrate FortiAnalyzer as a Fabric Connector, you can leverage the Event Handlers you create on FortiAnalyzer as a trigger on a FortiGate. Let’s take a practical example.

    FortiAnalyzer comes with the Default-Brute-Force-Account-Login-Attack-FGT event handler, which does exactly what it says. It recognizes brute force administrator logins on a FortiGate. It uses a correlation sequence, where failed logins are checked, and if they aren’t followed by a successful login after 5 minutes, the event triggers.

    If we want to use this event handler on our FortiGate, we clone it, because event handlers of the Built-in origin cannot be used for this, enable the Automation Stitch option, change the Threshold Duration to be the same as the NOT_FOLLOWED_BY value and then we go onto the FortiGate and create our trigger.

    FortiAnalyzer brute force login event handler
    FortiAnalyzer Event Handler trigger
    config system automation-trigger
        edit "FAZ-BRUTE-FORCE"
            set event-type faz-event
            set faz-event-name "Custom-Brute-Force-Account-Login-Attack-FGT"
        next
    end

    Debugging this communication is done on FortiAnalyzer using the oftpd application, like such (192.168.1.201 is my FortiGate):

    FAZVM64 # diagnose debug application oftpd 8 192.168.1.201
    oftpd debug filter:     filter(string)==192.168.1.201
    FAZVM64 # diagnose debug enable

    The full debugs for such an event handler triggering, with the regular communication removed, look like this:

    FortiAnalyzer oftpd debug output

    [T2313:oftp_restapi_util.c:1110] [FGT70GSERIAL] OFTP_RESTAPI_GENERIC_REQ sched success, uuid=e0a31dd8-4647-11f1-8ce6-000c29563bff, u
    rl=/api/v2/monitor/log/event/generate?vdom=root, data={ "log_type": "security-event", "fields": { "ackflag": "no", "alertid": "202605021
    000010016", "logcount": "5", "alerttime": "1777740995", "devid": "FGT70GSERIAL", "devname": "70G-01", "eventtype": "", "groupby1": "
    FGT70GSERIAL", "groupby2": "", "groupby3": "", "readflag": "no", "severity": "medium", "subject": "Brute force account login attack
    on FGT device FGT70GSERIAL detected", "tag": "Credential Access,login,attack", "triggername": "Custom-Brute-Force-Account-Login-Atta
    ck-FGT", "vdom": "root", "epid": "3", "euid": "3", "epip": "192.168.1.231", "srcip": "192.168.1.231", "dstip": "192.168.1.201", "epname"
    : "192.168.1.231", "euname": "N\/A", "extrainfo": "", "ephostname": "", "epmac": "", "eposname": "", "eposversion": "", "fctuid": "", "m
    itre_info": "{\"enterprise\": [\"T1110.001\"]}" } }.
    
    [T2320:oftp_restapi.c:592] [FGT70GSERIAL] req :
    POST /api/v2/monitor/log/event/generate?vdom=root HTTP/1.1
    Host: 127.0.0.1
    Connection: close
    User-Agent: FortiAnalyzer/7.6 (FortiAnalyzer-VM64; FAZ-VMTMSERIAL; v7.6.6-build3654 260127 (GA.M))
    Accept: */*
    Cookie: __fake_cookie_authorized_device;
    X-CSRFTOKEN: __fake_cookie_authorized_device
    Content-Length: 813
    Content-Type: application/x-www-form-urlencoded
    
    { "log_type": "security-event", "fields": { "ackflag": "no", "alertid": "202605021000010016", "logcount": "5", "alerttime": "1777740995"
    , "devid": "FGT70GSERIAL", "devname": "70G-01", "eventtype": "", "groupby1": "FGT70GSERIAL", "groupby2": "", "groupby3": "", "re
    adflag": "no", "severity": "medium", "subject": "Brute force account login attack on FGT device FGT70GSERIAL detected", "tag": "Cred
    ential Access,login,attack", "triggername": "Custom-Brute-Force-Account-Login-Attack-FGT", "vdom": "root", "epid": "3", "euid": "3", "ep
    ip": "192.168.1.231", "srcip": "192.168.1.231", "dstip": "192.168.1.201", "epname": "192.168.1.231", "euname": "N\/A", "extrainfo": "",
    "ephostname": "", "epmac": "", "eposname": "", "eposversion": "", "fctuid": "", "mitre_info": "{\"enterprise\": [\"T1110.001\"]}" } }
    
    [T2301:oftp_restapi_resp.c:1946] [FGT70GSERIAL] OFTP_TASK_T_GENERIC_RESP uuid=e0a31dd8-4647-11f1-8ce6-000c29563bff, url=/api/v2/moni
    tor/log/event/generate?vdom=root, req_data={ "log_type": "security-event", "fields": { "ackflag": "no", "alertid": "202605021000010016",
     "logcount": "5", "alerttime": "1777740995", "devid": "FGT70GSERIAL", "devname": "70G-01", "eventtype": "", "groupby1": "FGT70GSERIAL",
     "groupby2": "", "groupby3": "", "readflag": "no", "severity": "medium", "subject": "Brute force account login attack on FGT devi
    ce FGT70GSERIAL detected", "tag": "Credential Access,login,attack", "triggername": "Custom-Brute-Force-Account-Login-Attack-FGT", "v
    dom": "root", "epid": "3", "euid": "3", "epip": "192.168.1.231", "srcip": "192.168.1.231", "dstip": "192.168.1.201", "epname": "192.168.
    1.231", "euname": "N\/A", "extrainfo": "", "ephostname": "", "epmac": "", "eposname": "", "eposversion": "", "fctuid": "", "mitre_info":
     "{\"enterprise\": [\"T1110.001\"]}" } }

    First, we get the requests for a REST API action, which includes all information from the log that generated the event.

    Then we get the actual HTTP POST request towards the FortiGate, which includes the log information in JSON.

    Lastly, we get the response to the task.

    On the FortiGate, this communication can be seen in the httpsd application, because FortiAnalyzer sends a POST request after all.

    Enable the debugs and check the output.

    diagnose debug application httpsd -1
    diagnose debug enable
    
    [httpsd 4590 - 1777740996     info] fweb_debug_init[614] -- New POST request for "/api/v2/monitor/log/event/generate" from "/tmp/httpsd-faz.sock:0"
    [httpsd 4590 - 1777740996     info] fweb_debug_init[617] -- User-Agent: "FortiAnalyzer/7.6 (FortiAnalyzer-VM64; FAZ-VMTMSERIAL; v7.6.6-build3654 260127 (GA.M))"
    [httpsd 4590 - 1777740996     info] fweb_debug_init[622] -- Handler "api_monitor_v2-handler" assigned to request
    [httpsd 4590 - 1777740996     info] api_access_check_for_http_authd_session[51] -- Session key request authorized for daemon_admin.
    [httpsd 4590 - 1777740996     info] api_store_parameter[298] -- add API parameter 'vdom' (type=string)
    [httpsd 4590 - 1777740996     info] api_store_parameter[298] -- add API parameter 'vdom' (type=string)
    [httpsd 4590 - 1777740996     info] api_store_parameter[298] -- add API parameter 'log_type' (type=string)
    [httpsd 4590 - 1777740996     info] api_store_parameter[298] -- add API parameter 'fields' (type=object)
    [httpsd 4590 - 1777740996     info] api_endpoint_execute_handler[922] -- new API request (action='generate',path='log',name='event',vdom='root',user='daemon_admin')
    [httpsd 4590 - 1777740996     info] generate_event[1026] -- Created an internal event: ackflag="no" alertid="202605021000010016" logcount="5" alerttime="1777740995" devid="FGT70GSERIAL" devname="70G-01" eventtype="" groupby1="FGT70GSERIAL" groupby2="" groupby3="" readflag="no" severity="medium" subject="Brute force account login attack on FGT device FGT70GSERIAL detected" tag="Credential Access,login,attack" triggername="Custom-Brute-Force-Account-Login-Attack-FGT" vdom="root" epid="3" euid="3" epip="192.168.1.231" srcip="192.168.1.231" dstip="192.168.1.201" epname="192.168.1.231" euname="N\/A" extrainfo="" ephostname="" epmac="" eposname="" eposversion="" fctuid="" mitre_info="{\"enterprise\": [\"T1110.001\"]}"
    [httpsd 4590 - 1777740996     info] api_endpoint_execute_handler[937] -- completed API request (mem_start=55268, mem_end=55332, mem_change=64)
    [httpsd 4590 - 1777740996     info] fweb_debug_final[455] -- Completed POST request for "/api/v2/monitor/log/event/generate" (HTTP 200)

    There isn’t a lot going on here that we haven’t already seen, but we know it’s there.

    A FortiGate dynamically pulls the available event handlers every time you try to select one in the trigger, by the way. This is done using an OFTP JSONRPC request. This also shows up on FortiAnalyzer in the oftpd debugs:

    FortiGate requesting FortiAnalyzer Event Handlers

    [T2321:oftps.c:2148 :192.168.1.201] SSL clienthello incoming on sockfd[26]
    [T2321:oftps.c:1436 :192.168.1.201] dft-idx=0 inited=1.
    [T2321:oftps.c:1927 :192.168.1.201] SSL socket[26] pid[1688] ssl[0x7f3e50041820] SSL_new() success.
    [T2321:oftps.c:1804 :192.168.1.201] ssl verify peer cert
    [T2321:oftps.c:1826 :192.168.1.201] Peer is using a fortinet certificate. ON=Fortinet
    [T2321:oftps.c:1839 :192.168.1.201] Peer cert info, CommonName(CN=FGT70GSERIAL).
    [T2321:oftps.c:2160 :192.168.1.201] SSL_accept one client SUCCESS [ protocol : (772) TLS 1.3  ]
    [T2321:oftps.c:2199 :192.168.1.201] SSL socket[26] pid[1688] ssl[0x7f3e50041820] SSL_accepted
    [T2322:oftps.c:2257 :192.168.1.201] SSL socket[26] pid[1688] ssl[0x7f3e50041820] received [512] bytes:
    [T2322:oftps.c:2257 :192.168.1.201] SSL socket[26] pid[1688] ssl[0x7f3e50041820] received [83] bytes:
    [T2322:main.c:5147 :192.168.1.201] handle LOGIN_REQUEST_LEGACY
    [T2323:login.c:3539 :192.168.1.201] login-ver: 0.0
    [T2323:login.c:3443 :192.168.1.201] host = 'FGT-70G01'
    [T2323:login.c:3489 :192.168.1.201] Version: FortiGate-70G v8.0.0,build0167,260420 (GA.F)
    Virus-DB: 1.00001(2026-04-02 13:48)
    IPS-DB: 36.00208(2026-04-23 01:22)
    APP-DB: 36.00206(2026-04-21 03:52)
    FMWP-DB: 26.00040(2026-04-10 09:35)
    Industrial-DB: 6.00741(2015-12-01 02:30)
    Serial-Number: FGT70GSERIAL
    Virtual domain configuration: disable
    Current HA mode: a-p
    Current HA group: labcluster
    [T2323:login.c:348 :192.168.1.201] os_type(0) os_ver(8) mr(0) patch(0) build(167) beta(-1)
    [T2323:login.c:398 :192.168.1.201] ha_group_name:labcluster, ha_mode:1.
    [T2323:login.c:3448 :192.168.1.201] vdom = 1
    [T2323:login.c:3468 :192.168.1.201] tz-offset = 7200, tz-name = Europe/Vienna
    [T2323:oftps.c:2323 FGT70GSERIAL:192.168.1.201] SSL socket[26] pid[1688] ssl[0x7f3e50041820] sent [103] bytes:
    [T2323:login.c:3943 FGT70GSERIAL:192.168.1.201] login succeed
    [T2322:oftps.c:2257 FGT70GSERIAL:192.168.1.201] SSL socket[26] pid[1688] ssl[0x7f3e50041820] received [236] bytes:
    [T2322:main.c:5147 FGT70GSERIAL:192.168.1.201] handle RESTAPI REQUEST
    [T2329:oftp_svc_fwd.c:812 FGT70GSERIAL:192.168.1.201] handle_oftp_jsonrpc_request entry
    [T2329:oftp_svc_fwd.c:726 FGT70GSERIAL:192.168.1.201] cannot find display-timezone for FGT70GSERIAL
    [T2329:oftp_svc_fwd.c:898 FGT70GSERIAL:192.168.1.201] sent req={ "id": 1777742118, "params": [ { "url": "eventmgmt\/dev\/FGT70GSERIAL
    \/vdom\/root\/config\/trigger", "apiver": 3, "filter": [ "handlertype", "==", "handler-type-remote" ] } ], "jsonrpc": "2.0", "metho
    d": "get" }
     response '{ "jsonrpc": "2.0", "id": 1777742118, "result": { "status": { "code": 0, "message": "OK" }, "data": [ { "auto-raise-incident"
    : 0, "automation-stitch": 1, "creation-time": 0, "description": "Default event handler to detect botnet communication and report to Fort
    iGate", "enable": 1, "enable-time": 0, "handler-id": "30000", "mitre-domain": "enterprise", "mitre-info": "{\"enterprise\": [\"T1584.005
    \", \"T1071\"]}", "mitre-tech-id": "T1584.005,T1071", "name": "Default-Botnet-Communication-Detection", "protected": 1, "rule": [ { "agg
    regate-expr": "COUNT(*) >= 1", "devtype": 0, "enable": 1, "eventstatus": "open", "eventtype": null, "extrainfo": "C&C: ${dstip}:${dstpor
    t}, Reference: ${ref}", "extrainfo-type": 1, "filter": null, "filter-expr": "logid==0202009248", "filter-relation": 0, "groupby1": "endp
    oint", "groupby2": "virus", "groupby3": null, "indicator": null, "logtype": "virus", "name": "Traffic to Botnet CnC blocked in virus log
    ", "risk-severity": 4, "rule-id": "1", "severity": 1, "subject": "Traffic to Botnet C&C $groupby2 blocked", "tags": "Default,Botnet,IP,C
    &C", "thres-duration": 1440, "utmevent": null }, { "aggregate-expr": "COUNT(*) >= 1", "devtype": 0, "enable": 1, "eventstatus": "open",
    "eventtype": null, "extrainfo": "C&C: ${dstip}:${dstport}, Reference: ${ref}", "extrainfo-type": 1, "filter": null, "filter-expr": "logi
    d==0202009249", "filter-relation": 0, "groupby1": "endpoint", "groupby2": "virus", "groupby3": null, "indicator": null, "logtype": "viru
    s", "name": "Traffic to Botnet CnC detected in virus log", "risk-severity": 4, "rule-id": "2", "severity": 0, "subject": "Traffic to Bot
    net C&C $groupby2 detected", "tags": "Default,Botnet,IP,C&C", "thres-duration": 1440, "utmevent": null }, { "aggregate-expr": "COUNT(*)
    >= 1", "devtype": 0, "enable": 1, "eventstatus": "open", "eventtype": null, "extrainfo": "Traffic path: ${devname} (Policy ID:${policyid
    })\\${dstintf}\\${dstip}", "extrainfo-type": 1, "filter": null, "filter-expr": "logid==1501054601 OR logid==1501054600", "filter-relatio
    n": 0, "groupby1": "endpoint", "groupby2": "qname", "groupby3": null, "indicator": null, "logtype": "dns", "name": "DNS traffic to Botne
    t CnC blocked", "risk-severity": 4, "rule-id": "3", "severity": 1, "subject": "DNS traffic to Botnet C&C $groupby2 blocked", "tags": "De
    fault,Botnet,Domain,C&C", "thres-duration": 1440, "utmevent": null }, { "aggregate-expr": "COUNT(*) >= 1", "devtype": 0, "enable": 1, "e
    ventstatus": "open", "eventtype": null, "extrainfo": "C&C: ${dstip}:${dstport}, Traffic path: ${devname} (Policy ID:${policyid})\\${dsti
    ntf}, Reference: ${ref}", "extrainfo-type": 1, "filter": null, "filter-expr": "attack ~ Botnet and direction=incoming and (action=='dete
    cted' or action=='pass session')", "filter-relation": 0, "groupby1": "endpoint", "groupby2": "attack", "groupby3": null, "indicator": nu
    ll, "logtype": "ips", "name": "Traffic to Botnet CnC detected in ips log 1", "risk-severity": 4, "rule-id": "4", "severity": 0, "subject
    ": "Traffic to Botnet C&C $groupby2 detected", "tags": "Default,Botnet,Signature,C&C,Incoming", "thres-duration": 1440, "utmevent": null
     }, { "aggregate-expr": "COUNT(*) >= 1", "devtype": 0, "enable": 1, "eventstatus": "open", "eventtype": null, "extrainfo": "C&C: ${dstip
    }:${dstport}, Traffic path: ${devname} (Policy ID:${policyid})\\${dstintf}, Reference: ${ref}", "extrainfo-type": 1, "filter": null, "fi
    lter-expr": "attack ~ Botnet and direction=incoming and action!='detected' and action!='pass session'", "filter-relation": 0, "groupby1"
    : "endpoint", "groupby2": "attack", "groupby3": null, "indicator": null, "logtype": "ips", "name": "Traffic to Botnet CnC blocked in ips
     log 1", "risk-severity": 4, "rule-id": "5", "severity": 1, "subject": "Traffic to Botnet C&C $groupby2 blocked", "tags": "Default,Botne
    t,Signature,C&C,Incoming", "thres-duration": 1440, "utmevent": null }, { "aggregate-expr": "COUNT(*) >= 1", "devtype": 0, "enable": 1, "
    eventstatus": "open", "eventtype": null, "extrainfo": "C&C: ${srcip}:${srcport}, Traffic path: ${devname} (Policy ID:${policyid})\\${src
    intf}, Reference: ${ref}", "extrainfo-type": 1, "filter": null, "filter-expr": "attack ~ Botnet and direction=outgoing and (action=='det
    ected' or action=='pass session')", "filter-relation": 0, "groupby1": "dstendpoint", "groupby2": "attack", "groupby3": null, "indicator"
    : null, "logtype": "ips", "name": "Traffic from Botnet CnC detected in ips log", "risk-severity": 4, "rule-id": "6", "severity": 0, "sub
    ject": "Traffic from Botnet C&C $groupby2 detected", "tags": "Default,Botnet,Signature,C&C,Outgoing", "thres-duration": 1440, "utmevent"
    : null }, { "aggregate-expr": "COUNT(*) >= 1", "devtype": 0, "enable": 1, "eventstatus": "open", "eventtype": null, "extrainfo": "C&C: $
    {srcip}:${srcport}, Traffic path: ${devname} (Policy ID:${policyid})\\${srcintf}, Reference: ${ref}", "extrainfo-type": 1, "filter": nul
    l, "filter-expr": "attack ~ Botnet and direction=outgoing and action!='detected' and action!='pass session'", "filter-relation": 0, "gro
    upby1": "dstendpoint", "groupby2": "attack", "groupby3": null, "indicator": null, "logtype": "ips", "name": "Traffic from Botnet CnC blo
    cked in ips log", "risk-severity": 4, "rule-id": "7", "severity": 1, "subject": "Traffic from Botnet C&C $groupby2 blocked", "tags": "De
    fault,Botnet,Signature,C&C,Outgoing", "thres-duration": 1440, "utmevent": null }, { "aggregate-expr": "COUNT(*) >= 1", "devtype": 0, "en
    able": 1, "eventstatus": "open", "eventtype": null, "extrainfo": "C&C: ${dstip}:${dstport}, Reference: ${ref}", "extrainfo-type": 1, "fi
    lter": null, "filter-expr": "logid==0422016400", "filter-relation": 0, "groupby1": "endpoint", "groupby2": "attack", "groupby3": null, "
    indicator": null, "logtype": "ips", "name": "Traffic to Botnet CnC blocked in ips log 2", "risk-severity": 4, "rule-id": "8", "severity"
    : 1, "subject": "Traffic to Botnet C&C $groupby2 blocked", "tags": "Default,Botnet,IP,C&C", "thres-duration": 1440, "utmevent": null },
    { "aggregate-expr": "COUNT(*) >= 1", "devtype": 0, "enable": 1, "eventstatus": "open", "eventtype": null, "extrainfo": "C&C: ${dstip}:${
    dstport}, Reference: ${ref}", "extrainfo-type": 1, "filter": null, "filter-expr": "logid==0422016401", "filter-relation": 0, "groupby1":
     "endpoint", "groupby2": "attack", "groupby3": null, "indicator": null, "logtype": "ips", "name": "Traffic to Botnet CnC detected in ips
     log 2", "risk-severity": 4, "rule-id": "9", "severity": 0, "subject": "Traffic to Botnet C&C $groupby2 detected", "tags": "Default,Botn
    et,IP,C&C", "thres-duration": 30, "utmevent": null } ], "template-url": "\/fazcfg-template\/basic-handler\/fgt", "update-time": 0, "vers
    ion": 2, "handlertype": 1, "correlationtype": "basic-handler" }, { "auto-raise-incident": 0, "automation-stitch": 1, "content-pack-id":
    "", "content-pack-uuid": "", "creation-time": 1777736984, "data-selector": "", "description": "", "enable": 1, "enable-time": 0, "handle
    r-id": "679_809_2dd_c09", "mitre-info": "", "name": "SINGLE-LOGIN-FAILED", "notification": "", "protected": 0, "rule": [ { "aggregate-ex
    pr": "COUNT(*)>=1", "devtype": 0, "enable": 1, "eventstatus": "auto", "eventtype": null, "extrainfo": null, "extrainfo-type": 0, "filter
    ": [ { "id": 1, "key": "logid", "oper": 0, "value": "0100032002" } ], "filter-expr": "", "filter-relation": 1, "groupby1": "srcip", "gro
    upby2": "", "groupby3": "", "indicator": null, "logtype": "event", "name": "SINGLE-LOGIN-FAILED", "risk-severity": 4, "rule-id": "401_4b
    9_c06_724", "severity": 2, "tags": "", "thres-duration": 1, "utmevent": "system" } ], "template-url": "", "update-time": 1777742099, "uu
    id": "", "version": 2, "handlertype": 1, "correlationtype": "basic-handler" }, { "auto-raise-incident": 0, "automation-stitch": 1, "cont
    ent-pack-id": "", "content-pack-uuid": "", "creation-time": 1777735167, "data-selector": "", "description": "This handler is to detect i
    f an account login failed many times not followed by a login success for FortiGate.", "enable": 1, "enable-time": 0, "eventstatus": "aut
    o", "extrainfo-type": 0, "handler-id": "360_fba_9a7_67b", "indicator": null, "mitre-domain": "enterprise", "mitre-info": "{\"enterprise\
    ": [\"T1110.001\"]}", "mitre-tech-id": "T1110.001", "name": "Custom-Brute-Force-Account-Login-Attack-FGT", "notification": "", "protecte
    d": 0, "risk-severity": 4, "rule": [ { "aggregate-expr": "COUNT(*) >= 5", "devtype": 0, "filter": [ { "id": 1, "key": "logid", "oper": 0
    , "value": "0100032002" } ], "filter-expr": "", "filter-relation": 1, "groupby1": "devid", "groupby2": "", "groupby3": "", "logtype": "e
    vent", "name": "Login Failed 5 times", "rule-id": "rule_1", "utmevent": "system" }, { "aggregate-expr": "COUNT(*)>=1", "devtype": 0, "fi
    lter": [ { "id": 1, "key": "logid", "oper": 0, "value": "0100032001" } ], "filter-expr": "", "filter-relation": 1, "groupby1": "devid",
    "groupby2": "", "groupby3": "", "logtype": "event", "name": "Login-Success", "rule-id": "rule_2", "utmevent": "system" } ], "rule-relati
    on": "rule_1 NOT_FOLLOWED_BY[1m] rule_2", "rule-relation-constraint": "rule_1.devid=rule_2.devid", "severity": 2, "subject": "Brute forc
    e account login attack on FGT device ${Login Failed 5 times.groupby1} detected", "tags": "Credential Access,login,attack", "template-url
    ": "\/fazcfg-template\/correlation-handler\/fgt", "thres-duration": 1, "update-time": 1777736344, "uuid": "", "version": 2, "handlertype
    ": 1, "correlationtype": "correlation-handler" } ] } }', len=9427.
    
    [T2322:oftps.c:2235 FGT70GSERIAL:192.168.1.201] The SSL/TLS connection has been closed
    [T2322:main.c:937 FGT70GSERIAL:192.168.1.201] Client connection closed. Reason 0(OK)
    [T2322:oftps.c:2342 FGT70GSERIAL:192.168.1.201] SSL pid[1688] ssl[0x7f3e50041820] shuting down sockfd[26] ip[192.168.1.201] connecte
    d[1]
    [T2322:oftps.c:2361 FGT70GSERIAL:192.168.1.201] SSL_shutdown SUCCESS
    [T2322:oftps.c:2369 FGT70GSERIAL:192.168.1.201] SSL socket[26] pid[1688] ssl[0x7f3e50041820] destroy_SSL_context

    With FortiAnalyzer Event Handlers, the sky really is the limit. You can do practically everything your heart desires if you speak FortiAnalyzer.

    Auto-scripts

    The next automation option is auto-scripts, which are CLI-only in config system auto-script, and not that well-known, but they are powerful. Just look at the options:

    70G-02 (LAB-SCRIPT) # set
    interval       Repeat interval in seconds.
    repeat         Number of times to repeat this script (0 = infinite).
    start          Script starting mode.
    script         List of FortiOS CLI commands to repeat.
    output-size    Number of megabytes to limit script output to (10 - 1024, default = 10).
    timeout        Maximum running time for this script in seconds (0 = no timeout).

    The script option takes whatever FortiOS CLI commands you want, but there is a limit of 1023 characters, so you won’t be able to import certificates.

    You can set the interval, how often the script runs and if it should start automatically or be executed manually.

    If you ever need to escape some characters in the script, like double quotes (), you can do that using backslashes \ as can be seen here:

    config system auto-script
        edit "LAB-SCRIPT"
            set start auto
            set script "config firewall address
    edit \"TEST\"
    set subnet 192.0.2.0/24
    set comment \"Escape double quotes\"
    next
    end
    "
        next
    end

    Auto-scripts have the nice advantage of being able to be created by outside systems but run with local privileges on a FortiGate, which becomes important if you try to automate things that outside systems aren’t allowed to do. A good example is FortiManager. FortiManager cannot change some options on a FortiGate, like certain options related to HA or the central management options itself, meaning FortiManager cannot change the FortiManager options on a FortiGate.

    To get around these limitations, you can create an auto-script on FortiManager and change whatever you need in that.

    For debugging around auto-scripts, you use the execute auto-script command, and lots of information is available in this technical tip: Technical Tip: Automated scripts (auto-script). Execution, testing and verification explained with examples 

    Importantly, auto-scripts can be created and controlled in automation actions, which opens up a lot of possibilities.

    One use case for auto-scripts I want to highlight is regarding the execute auto-script result option. Since this command shows the result of an auto-script and auto-scripts can run diagnose and get commands, you can use this as a crude information storage.

    Imagine that you run into the dreaded conserve mode, and you want to gather some information during that time, like which process took the memory. FortiGates come with a Conserve Mode automation trigger, and if you create the following automation action, you can later check what the status of your processes was using diagnose sys top 1 99 1 when conserve mode was triggered.

    config system auto-script
        edit "LAB-SCRIPT-TOP"
            set script "diagnose sys top 1 99 1"
        next
    end

    The reason I am performing an execute auto-script start LAB-SCRIPT-TOP after creating the auto-script is because setting set start auto is, for some reason, not possible with an automation action.

    ========== #1, 2026-05-01 12:39:06 ==========
    70G-02  config system auto-script
    70G-02 (auto-script)      edit "LAB-SCRIPT-TOP"
    70G-02 (LAB-SCRIPT-TOP)          set start auto
    Script start-mode could not be changed from auto-script.
    node_check_object fail! for start auto
    
    value parse error before 'auto'
    Command fail. Return code -37
    70G-02 (LAB-SCRIPT-TOP)          set script "diagnose sys top 1 99 1"
    70G-02 (LAB-SCRIPT-TOP)      next
    70G-02 (auto-script)  end
    70G-02  execute auto-script start LAB-SCRIPT-TOP
    
    ======= end of #1, 2026-05-01 12:39:06 ======

    Once your conserve mode problem has been solved, or it has solved itself, run an execute auto-script result LAB-SCRIPT-TOP and check your output.

    Auto-script output

    70G-02 # execute auto-script result LAB-SCRIPT-TOP
    Script LAB-SCRIPT-TOP output:
    
    ========== #1, 2026-05-02 18:13:44 ==========
    70G-02  diagnose sys top 1 99 1
    06:13:46 PM up 0 days, 2 hours and 21 minutes
    0U, 0N, 0S, 100I, 0WA, 0HI, 0SI, 0ST; 3708T, 2315F
             miglogd     1714      S       0.5     1.3    2
                node     1572      S       0.0     2.6    3
           ipsengine     1893      S       0.0     2.1    3
           ipshelper     1604      S       0.0     2.1    2
           ipsengine     1895      S       0.0     2.1    2
           ipsengine     1894      S       0.0     2.1    0
                 wad     1665      S       0.0     1.6    3
                 wad     1663      S       0.0     1.5    1
             cmdbsvr     1484      S       0.0     1.5    0
              cw_acd     1614      S       0.0     1.3    2
           forticron     1558      S       0.0     1.3    0
           extenderd     1631      S       0.0     1.2    1
             miglogd     1570      S       0.0     1.2    0
                csfd     1634      S       0.0     1.2    3
                 wad     1664      S       0.0     1.2    0
              newcli     5113      S       0.0     1.2    3
              newcli     2866      S       0.0     1.2    2
              httpsd     5707      S       0.0     1.1    1
                 wad     1656      S       0.0     1.1    0
               fgfmd     1613      S       0.0     1.1    2
     initXXXXXXXXXXX        1      S       0.0     1.1    3
                 wad     1580      S       0.0     1.0    1
          http_authd     1551      S       0.0     1.0    2
                 wad     1659      S       0.0     1.0    0
                 wad     1657      S       0.0     0.9    2
                iked     1718      S       0.0     0.9    3
                 wad     1661      S       0.0     0.9    1
                 wad     1662      S       0.0     0.8    0
             fgtlogd     1593      S       0.0     0.8    3
                 wad     1655      S       0.0     0.8    0
                 wad     1660      S       0.0     0.8    1
                 wad     1658      S       0.0     0.8    2
              authd3     1726      S       0.0     0.8    1
              csfd 0     1964      S       0.0     0.8    3
              authd2     1725      S       0.0     0.8    3
     eap_proxy_worke     1728      S       0.0     0.8    3
                iked     1716      S       0.0     0.8    3
          telemetryd     1633      S       0.0     0.8    1
                iked     1717      S       0.0     0.7    0
              fcnacd     1567      S       0.0     0.7    0
            dnsproxy     1617      S       0.0     0.7    3
           forticldd     1559      S       0.0     0.7    1
              cu_acd     1623      S       0.0     0.7    0
              httpsd     1550      S       0.0     0.7    2
                 imi     1546      S       0.0     0.7    3
                bgpd     1543      S <     0.0     0.7    2
                 nsm     1542      S       0.0     0.7    3
             updated     1589      S       0.0     0.7    0
               authd     1560      S       0.0     0.7    2
              fltund     1626      S       0.0     0.6    0
               snmpd     1596      S       0.0     0.6    3
          cid-config     1610      S N     0.0     0.6    2
              fnbamd     1557      S       0.0     0.6    3
             fortimq     1637      S       0.0     0.6    3
                iotd     1598      S       0.0     0.6    1
               voipd     1579      S       0.0     0.6    0
             foauthd     1561      S       0.0     0.6    1
                sshd     1600      S       0.0     0.6    3
             wpad_ac     1618      S       0.0     0.6    2
                ntpd     1599      S <     0.0     0.6    2
              flcfgd     1624      S       0.0     0.6    2
          http_authd     1554      S       0.0     0.5    2
               radvd     1603      S       0.0     0.5    2
                iked     1715      S       0.0     0.5    0
           forticron     5778      S       0.0     0.5    2
              lnkmtd     1601      S       0.0     0.5    3
               autod     1635      S       0.0     0.5    2
      zebos_launcher     1527      S       0.0     0.5    3
           cloudapid     1636      S       0.0     0.5    3
                sshd     2865      S       0.0     0.4    3
             syslogd     1591      S       0.0     0.4    3
           locallogd     1595      S       0.0     0.4    2
           forticron     5779      R       0.0     0.4    3
           forticron     5777      S       0.0     0.4    1
            cid-scan     1620      S       0.0     0.4    0
          fortilinkd     1622      S       0.0     0.4    2
                 vwl     1602      S       0.0     0.4    2
      ikecryptd_dhw0     1583      S       0.0     0.4    3
      ikecryptd_dhw2     1585      S       0.0     0.4    3
      ikecryptd_dhw1     1584      S       0.0     0.4    3
          ipsmonitor     1555      S       0.0     0.3    1
              flpold     1625      S       0.0     0.3    2
       cw_acd_helper     1733      S       0.0     0.3    2
            dnsproxy     1606      S       0.0     0.3    1
        ipmc_sensord     1548      S       0.0     0.3    1
              dhcp6c     1630      S       0.0     0.3    1
           ikecryptd     1581      S       0.0     0.3    1
               getty     1552      S <     0.0     0.3    0
           alertmail     1605      S       0.0     0.3    3
           eap_proxy     1611      S       0.0     0.3    3
      merged_daemons     1556      S       0.0     0.3    2
           clearpass     1563      S       0.0     0.3    3
             uploadd     1547      S       0.0     0.3    2
           fsso_ldap     1569      S       0.0     0.3    3
                 fas     1566      S       0.0     0.3    1
            httpclid     1565      S       0.0     0.3    2
                 fsd     1629      S       0.0     0.3    1
            kmiglogd     1549      S       0.0     0.3    3
               getty     1553      S       0.0     0.2    3
    
    ======= end of #1, 2026-05-02 18:13:47 ======

    You can of course also send a mail with all this information, as is detailed in this technical tip: Technical Tip: Automation stitch for conserve mode

    Batch mode

    The last topic for automation I want to write about is batch mode. This isn’t a way to automate something, but you can use it in automation, so it gets a spot.

    Batch mode is something you start, write down your CLI commands, and once you end the mode, the commands get executed. This allows you to make lots of changes in a single “transaction”, not to be confused with the actual config transactions, where the individual configurations might cut off your access at some point.

    Imagine you are managing a FortiGate via a VPN, and you need to drastically change the VPN configuration, which would normally disconnect you somewhere along the configuration. With batch mode, you can do this all without disruption.

    One very nice thing about batch mode is that you don’t have to worry about formatting, character limits or anything of the sort. Since you’re working like normal on the CLI, you can do just about every configuration. In the auto-script section, I mentioned how the character limit is 1023, so importing certificates won’t work. This is no problem with batch mode.

    Batch mode certificate import

    70G-02 # execute batch start
    
    Enter batch mode...
    
    70G-02 # config vpn certificate remote
    70G-02 #     edit "TEST-CERT"
    70G-02 #         set remote "-----BEGIN CERTIFICATE-----
    > MIIHSzCCBTOgAwIBAgITMgAAADd86bYatj17eQACAAAANzANBgkqhkiG9w0BAQ0F
    > ADBVMRMwEQYKCZImiZPyLGQBGRYDY29tMRkwFwYKCZImiZPyLGQBGRYJbGFiZG9t
    > YWluMRIwEAYKCZImiZPyLGQBGRYCYWQxDzANBgNVBAMTBldJTi1DQTAeFw0yNTA4
    > MDkxOTU0MDdaFw0yNzA4MDkxOTU0MDdaMCMxITAfBgNVBAMMGGxhYnVzZXJAYWQu
    > bGFiZG9tYWluLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALrJ
    > EQqx06rKJ9OULN2155KGM31WjJvUDM+aPEiHgmAUE9pPmob/kLzQ97ylnj3AzY9e
    > FF+coqgphfpxdLhlYEOUSAzQsK2B/Jn7MS8dhwfaYGtVvtfEjOZa833pXWmz1bQE
    > Onfrnp1n3QZL6WYkSLHUbBESQY6tmfzOohcu9rfNfMVFEsPz/MPTHOFkqn5CfPMT
    > p9gV3X3/UhoWrXBphUfKjHf5+NY2kBVySRIsaFxv/yTI/ypbc0Zk3a7Ux4dJRRcd
    > jIGfHxoN6d9EphlMd/fEY27yJ0Ul1HZJh7skRsP9y0egMQtbEbsOmCxeyXysNuLe
    > bTMYVxJ5w9DFB+bbmYUCAwEAAaOCA0QwggNAMCcGA1UdJQQgMB4GCCsGAQUFBwMC
    > BggrBgEFBQcDBAYIKwYBBQUHAwEwMwYJKwYBBAGCNxUKBCYwJDAKBggrBgEFBQcD
    > AjAKBggrBgEFBQcDBDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUTQ/QuOQjE0fjDKCy
    > ajTzs7KtenYwDgYDVR0PAQH/BAQDAgWgMCMGA1UdEQQcMBqCGGxhYnVzZXJAYWQu
    > bGFiZG9tYWluLmNvbTAfBgNVHSMEGDAWgBTzzdZs1ME1aNfuqgd6qFpzuEhs1TCC
    > AQcGA1UdHwSB/zCB/DCB+aCB9qCB84aBt2xkYXA6Ly8vQ049V0lOLUNBKDIpLENO
    > PVdJTi1BRCxDTj1DRFAsQ049UHVibGljJTIwS2V5JTIwU2VydmljZXMsQ049U2Vy
    > dmljZXMsQ049Q29uZmlndXJhdGlvbixEQz1hZCxEQz1sYWJkb21haW4sREM9Y29t
    > P2NlcnRpZmljYXRlUmV2b2NhdGlvbkxpc3Q/YmFzZT9vYmplY3RDbGFzcz1jUkxE
    > aXN0cmlidXRpb25Qb2ludIY3aHR0cDovL1dJTi1BRC5hZC5sYWJkb21haW4uY29t
    > L0NlcnRFbnJvbGwvV0lOLUNBKDIpLmNybDCCAR8GCCsGAQUFBwEBBIIBETCCAQ0w
    > ga0GCCsGAQUFBzAChoGgbGRhcDovLy9DTj1XSU4tQ0EsQ049QUlBLENOPVB1Ymxp
    > YyUyMEtleSUyMFNlcnZpY2VzLENOPVNlcnZpY2VzLENOPUNvbmZpZ3VyYXRpb24s
    > REM9YWQsREM9bGFiZG9tYWluLERDPWNvbT9jQUNlcnRpZmljYXRlP2Jhc2U/b2Jq
    > ZWN0Q2xhc3M9Y2VydGlmaWNhdGlvbkF1dGhvcml0eTBbBggrBgEFBQcwAoZPaHR0
    > cDovL1dJTi1BRC5hZC5sYWJkb21haW4uY29tL0NlcnRFbnJvbGwvV0lOLUFELmFk
    > LmxhYmRvbWFpbi5jb21fV0lOLUNBKDIpLmNydDA9BgkrBgEEAYI3FQcEMDAuBiYr
    > BgEEAYI3FQiFrZZuhK/KeoeNhyWH9NB3gfixAQWDyMVZhdXVWQIBZAIBCTANBgkq
    > hkiG9w0BAQ0FAAOCAgEAvQt0Mm/hcFZ4GX5KZj7mlj3Aqm4HyapaRSAHVp7eN+I2
    > kZiWtzn8WP6fEcniHNXn4hs/5c/MEBzpIbQ4z0UvymH2AHijiZAEq/zOqOlGCOjy
    > 8/lhRFY0cjGxg6GfpN7Pm9p0PEEOGKvapn92hxE/rAL4S5m4jxu9Nukfcw8mcb7g
    > kaGr/3HAlRcsttU0VyJichmfBOYutpj3mhJ38Sc7eCxbTqj+U71NSjdrNx2WN2jh
    > PAhS31tP1YTyNjW7Tgrn9unplxi2BqjDc0j72/247/E6DBdroV5HiQ2F3e2u1eXk
    > Ss3IKYKtU1UixDAYsN28Nir4u87P4yo1xcpGTHr5Qne1E2tHNH1jb951MZPMuhh6
    > bSIm+U0b0EzNgfQp6wI+a/9ZtliZfpxrvzyp7mPwhri9ZbNtDkWPTOjjWrw4yEVZ
    > /e+UFt9ghYB0xSvllvd8fquzG5bgOv46EfwSw0J8a4PGwy4Wy7kiyjrsphimNY6/
    > ql8WEeg48QTukluhULsKosO1JNx6RLe85gCcQ7Jb9Q77q2DJiTHjqu7OACns4DkV
    > BKPtYE0+40/TOCc4GgsRHk/iLy0/vnh7KH5ITblokygKMFAjhBlmSmdhajK/7Vyl
    > cWMKYzZMGk6FvNU8XXx/fjVc8dL2lj+P3E6UOvtYtX4Yb3TuVUp+NtJSJZ6eLiA=
    > -----END CERTIFICATE-----"
    70G-02 #     next
    70G-02 # end
    70G-02 # execute batch end
    Exit and run batch commands...
    
    70G-02 # execute batch lastlog
    0: config vpn certificate remote
    0:     edit "TEST-CERT"
    0:         set remote "-----BEGIN CERTIFICATE-----
    0: MIIHSzCCBTOgAwIBAgITMgAAADd86bYatj17eQACAAAANzANBgkqhkiG9w0BAQ0F
    0: ADBVMRMwEQYKCZImiZPyLGQBGRYDY29tMRkwFwYKCZImiZPyLGQBGRYJbGFiZG9t
    0: YWluMRIwEAYKCZImiZPyLGQBGRYCYWQxDzANBgNVBAMTBldJTi1DQTAeFw0yNTA4
    0: MDkxOTU0MDdaFw0yNzA4MDkxOTU0MDdaMCMxITAfBgNVBAMMGGxhYnVzZXJAYWQu
    0: bGFiZG9tYWluLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALrJ
    0: EQqx06rKJ9OULN2155KGM31WjJvUDM+aPEiHgmAUE9pPmob/kLzQ97ylnj3AzY9e
    0: FF+coqgphfpxdLhlYEOUSAzQsK2B/Jn7MS8dhwfaYGtVvtfEjOZa833pXWmz1bQE
    0: Onfrnp1n3QZL6WYkSLHUbBESQY6tmfzOohcu9rfNfMVFEsPz/MPTHOFkqn5CfPMT
    0: p9gV3X3/UhoWrXBphUfKjHf5+NY2kBVySRIsaFxv/yTI/ypbc0Zk3a7Ux4dJRRcd
    0: jIGfHxoN6d9EphlMd/fEY27yJ0Ul1HZJh7skRsP9y0egMQtbEbsOmCxeyXysNuLe
    0: bTMYVxJ5w9DFB+bbmYUCAwEAAaOCA0QwggNAMCcGA1UdJQQgMB4GCCsGAQUFBwMC
    0: BggrBgEFBQcDBAYIKwYBBQUHAwEwMwYJKwYBBAGCNxUKBCYwJDAKBggrBgEFBQcD
    0: AjAKBggrBgEFBQcDBDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUTQ/QuOQjE0fjDKCy
    0: ajTzs7KtenYwDgYDVR0PAQH/BAQDAgWgMCMGA1UdEQQcMBqCGGxhYnVzZXJAYWQu
    0: bGFiZG9tYWluLmNvbTAfBgNVHSMEGDAWgBTzzdZs1ME1aNfuqgd6qFpzuEhs1TCC
    0: AQcGA1UdHwSB/zCB/DCB+aCB9qCB84aBt2xkYXA6Ly8vQ049V0lOLUNBKDIpLENO
    0: PVdJTi1BRCxDTj1DRFAsQ049UHVibGljJTIwS2V5JTIwU2VydmljZXMsQ049U2Vy
    0: dmljZXMsQ049Q29uZmlndXJhdGlvbixEQz1hZCxEQz1sYWJkb21haW4sREM9Y29t
    0: P2NlcnRpZmljYXRlUmV2b2NhdGlvbkxpc3Q/YmFzZT9vYmplY3RDbGFzcz1jUkxE
    0: aXN0cmlidXRpb25Qb2ludIY3aHR0cDovL1dJTi1BRC5hZC5sYWJkb21haW4uY29t
    0: L0NlcnRFbnJvbGwvV0lOLUNBKDIpLmNybDCCAR8GCCsGAQUFBwEBBIIBETCCAQ0w
    0: ga0GCCsGAQUFBzAChoGgbGRhcDovLy9DTj1XSU4tQ0EsQ049QUlBLENOPVB1Ymxp
    0: YyUyMEtleSUyMFNlcnZpY2VzLENOPVNlcnZpY2VzLENOPUNvbmZpZ3VyYXRpb24s
    0: REM9YWQsREM9bGFiZG9tYWluLERDPWNvbT9jQUNlcnRpZmljYXRlP2Jhc2U/b2Jq
    0: ZWN0Q2xhc3M9Y2VydGlmaWNhdGlvbkF1dGhvcml0eTBbBggrBgEFBQcwAoZPaHR0
    0: cDovL1dJTi1BRC5hZC5sYWJkb21haW4uY29tL0NlcnRFbnJvbGwvV0lOLUFELmFk
    0: LmxhYmRvbWFpbi5jb21fV0lOLUNBKDIpLmNydDA9BgkrBgEEAYI3FQcEMDAuBiYr
    0: BgEEAYI3FQiFrZZuhK/KeoeNhyWH9NB3gfixAQWDyMVZhdXVWQIBZAIBCTANBgkq
    0: hkiG9w0BAQ0FAAOCAgEAvQt0Mm/hcFZ4GX5KZj7mlj3Aqm4HyapaRSAHVp7eN+I2
    0: kZiWtzn8WP6fEcniHNXn4hs/5c/MEBzpIbQ4z0UvymH2AHijiZAEq/zOqOlGCOjy
    0: 8/lhRFY0cjGxg6GfpN7Pm9p0PEEOGKvapn92hxE/rAL4S5m4jxu9Nukfcw8mcb7g
    0: kaGr/3HAlRcsttU0VyJichmfBOYutpj3mhJ38Sc7eCxbTqj+U71NSjdrNx2WN2jh
    0: PAhS31tP1YTyNjW7Tgrn9unplxi2BqjDc0j72/247/E6DBdroV5HiQ2F3e2u1eXk
    0: Ss3IKYKtU1UixDAYsN28Nir4u87P4yo1xcpGTHr5Qne1E2tHNH1jb951MZPMuhh6
    0: bSIm+U0b0EzNgfQp6wI+a/9ZtliZfpxrvzyp7mPwhri9ZbNtDkWPTOjjWrw4yEVZ
    0: /e+UFt9ghYB0xSvllvd8fquzG5bgOv46EfwSw0J8a4PGwy4Wy7kiyjrsphimNY6/
    0: ql8WEeg48QTukluhULsKosO1JNx6RLe85gCcQ7Jb9Q77q2DJiTHjqu7OACns4DkV
    0: BKPtYE0+40/TOCc4GgsRHk/iLy0/vnh7KH5ITblokygKMFAjhBlmSmdhajK/7Vyl
    0: cWMKYzZMGk6FvNU8XXx/fjVc8dL2lj+P3E6UOvtYtX4Yb3TuVUp+NtJSJZ6eLiA=
    0: -----END CERTIFICATE-----"
    0:     next
    0: end

    You can, of course, use batch mode in combination with other automation options, like automation actions in stitches, but keep the 1023-character limit in mind.

    In combination with automation actions, I want to note that batch mode does not display output of the command being executed, so if you do a get system status in batch mode, you don’t have any output of this directly. You have to do an execute batch lastlog to get the output.

    A use case at the end: Push API feed

    A little-known feature a FortiGate has is being able to host feeds itself using the Push API method. The idea is that you use API requests to send IPs, MAC addresses, domains, etc., to the FortiGate; it adds or removes the entries from the feed, and you use the feed wherever it makes sense, like in firewall policies.

    One caveat is that the information in the feed is volatile and does not survive a reboot.

    Let’s combine a few things from this post to create an example.

    We start with our FortiAnalyzer Event Handler Custom-Brute-Force-Account-Login-Attack-FGT (this is the cloned event handler from above) as our trigger. If it gets triggered, we add the source IP from that event to the Push API feed using a webhook,ban the IP and also send an email notification with the log information.

    Push API stitch
    Push API feed valid

    Push API automation stitch configuration

    config system automation-trigger
        edit "FAZ-BRUTE-FORCE"
            set event-type faz-event
            set faz-event-name "Custom-Brute-Force-Account-Login-Attack-FGT"
        next
    end
    config system automation-action
        edit "PUSH-API-WEBHOOK"
            set action-type webhook
            set protocol https
            set uri "192.168.1.201/api/v2/monitor/system/external-resource/dynamic"
            set http-body "{ \"commands\": [{ \"name\": \"PUSH-FEED\", \"command\": \"add\", \"entries\": [ \"%%log.srcip%%\" ] } ]}"
            set port 8443
            config http-headers
                edit 1
                    set key "Authorization"
                    set value "4xdzrjdhdrs3417b51wqt4cw05p0rd"
                next
            end
            set verify-host-cert disable
        next
    end
    config system automation-stitch
        edit "FAZ-BRUTE-FORCE-PUSH-API"
            set status disable
            set trigger "FAZ-BRUTE-FORCE"
            config actions
                edit 3
                    set action "IP Ban"
                    set required enable
                next
                edit 2
                    set action "PUSH-API-WEBHOOK"
                    set required enable
                next
                edit 4
                    set action "Email Notification"
                    set required enable
                next
            end
        next
    end

    Wrapping up

    I realize that nothing I covered in this post is that advanced, but I saw some of the things I covered being asked a few times, and some things, like the log dumping, Push API feeds, and auto-scripts as information storage, are things I personally wanted to look at a bit more, and I turned it into a post.

    So, dear reader, thank you for your time in indulging me.

  • FortiAnalyzer playbook variables: How to help yourself

    As I was preparing for Accelerate 2026 and the Ultimate Fabric Challenge, one of the topics I wanted to focus on a lot more was Security Operations (SecOps), because this is an area I was always weak in.

    SecOps, in this context, means working with FortiAnalyzer automation, i.e. event handlers, playbooks, incidents and connectors. A part I really wanted to immerse myself in was how FortiAnalyzer handles variables, and in the process, I think I gained some useful knowledge that I now want to show the world. So, dear reader, please, read on, and maybe this proves useful to you. It was relatively quick for me to complete, but it’s not going to be short, though there are lots of pictures!

    The setup

    For this post, I have the following components:

    • FortiAnalyzer running 7.6.6
    • FortiGate running 7.6.6
    • FortiAuthenticator running 8.0.1
    • An Ubuntu server to receive some POST requests

    A primer on playbooks

    FortiAnalyzer uses playbooks to automate various workflows using connectors that integrate other systems, like FortiGates, FortiClient EMS, FortiAuthenticator, etc. Every playbook uses a trigger, which is usually an event or an incident, but you could also run it on demand or on a schedule. After this trigger various tasks are executed that define what connector is used, various parameters, and variables, to accomplish, well, lots of things.

    Playbooks can be simple, only sending a mail with information from an event, and fully orchestrate an incident response flow, by triggering on an event, creating an incident, running a report, banning IPs, sending mails, and you could go on and on, really. For this post, we’ll keep it simple and focus on how to work with variables.

    Setting the stage

    For the first type of variables, we’ll look at connectors and, in this case, specifically, the one for FortiAuthenticator (FAC). The FAC connector is one that works with HTTP-based communication, so whatever FortiAnalyzer sends to FAC can be easily read in cleartext.

    The FAC connector has a few actions, and for our example, we’ll look at two:

    1. Get User List, which fetches a type of user (LDAP, RADIUS, or local) and saves it in a list
    2. Update User Status, which we will use to deactivate a user using an ID

    I have prepared the following playbook:

    FortiAnalyzer FortiAuthenticator playbook

    There are four tasks in this:

    1. An On Demand trigger
    2. The Get User List task, using the FAC connector, to get the user list
    3. The Update User Status task, using the FAC connector, to deactivate a user by ID
    4. A generic webhook task that sends an HTTP POST request

    The generic webhook is what is used to demonstrate one way we can check what FortiAnalyzer sends.

    The configuration of the webhook looks like this:

    FortiAnalyzer generic webhook

    The protocol being HTTP is important.

    The target of the webhook is the Ubuntu server that uses http-echo-server to simply echo any incoming POST requests to the terminal.

    [Update 2026-03-26]: Chris Eddisford from Fortinet has shown me webhook.site, which you can use as a generic webhook receiver, so you can send your POST requests to your unique URL and immediately see the result. No sign-up or Ubuntu server needed. Thanks a lot, Chris!

    Here is the configuration of all tasks, except the On Demand trigger, because there isn’t anything to show for that one:

    If I run this playbook as is, it won’t actually do anything on FAC, because the Update User Status task expects a specific user ID, and not a list of users, but we’ll get to that. At this point, we care about what FortiAnalyzer sends.

    Note: I want to stress that this playbook exists purely as an exercise to show the process. In reality, you wouldn’t actually do this. In a production environment, you’d get the username from a triggered event, pass the username to the Get User action to retrieve the user ID and then use the Update User Status action with the user ID as your input.

    Getting the variable values from HTTP-based connectors

    If I run this playbook, the playbook will run successfully, and my Ubuntu server will get a POST request.

    FortiAnalyzer playbook execution

    Ubuntu POST echo

    [server] event: listening (port: 8081)
    [server] event: connection (socket#1)
    [socket#1] event: resume
    [socket#1] event: data
    --> POST / HTTP/1.1
    --> Host: 192.168.1.151:8081
    --> Accept-Encoding: identity
    --> User-Agent: python-urllib3/2.3.0
    --> Content-Length: 1164
    -->
    --> {"meta": {"limit": 10, "next": null, "offset": 0, "previous": null, "total_count": 2}, "objects": [{"active": true, "company": null, "department": null, "dn": "CN=manager,OU=USERS,OU=LAB,DC=ad,DC=labdomain,DC=com", "email": "", "fido": false, "first_name": "manager", "ftm_act_method": "", "id": 2, "is_locked": false, "last_name": "", "mobile_number": "", "reason": null, "recovery_by_question": false, "resource_uri": "/api/v1/ldapusers/2/", "server_address": "win-ad.ad.labdomain.com", "server_name": "WIN-AD", "token_auth": false, "token_serial": "", "token_type": "", "username": "manager@ad.labdomain.com"}, {"active": false, "company": "LAB", "department": "NetSec", "dn": "CN=adkevin,OU=USERS,OU=LAB,DC=ad,DC=labdomain,DC=com", "email": "adkevin@ad.labdomain.com", "fido": false, "first_name": "adkevin", "ftm_act_method": "email", "id": 1, "is_locked": false, "last_name": "LAB", "mobile_number": "", "reason": 0, "recovery_by_question": false, "resource_uri": "/api/v1/ldapusers/1/", "server_address": "win-ad.ad.labdomain.com", "server_name": "WIN-AD", "token_auth": false, "token_serial": "", "token_type": "", "username": "adkevin@ad.labdomain.com"}]}
    [socket#1] event: prefinish
    [socket#1] event: finish
    [socket#1] event: readable
    [socket#1] event: end
    [socket#1] event: close

    In the POST request we see that FortiAnalyzer uses a Python library to send the request (as a matter of fact, playbooks in general use Python, which you see clearly if you have an error), and we get the JSON payload consisting of two important dictionaries: meta, which has, as the name says, meta information, and objects, which holds a list of dictionaries that have the actual entries that were retrieved from FAC.

    You can paste the entire payload into your favourite JSON formatter, like JSON formatter, to make it more readable.

    JSON formatter view

    But let’s assume you don’t have a Linux server with this http-echo-server available, and you want to get the information using only FortiAnalyzer. Don’t worry, this requires only marginally more work.

    Important point: Before FortiAnalyzer sends a POST, it checks if it can connect to the destination over the specified port (it doesn’t matter if the port can do something with the payload), so if you don’t have a target with an open port, this won’t work. Luckily, FortiAnalyzer is definitely available, so you can use that as your target in the webhook connector as well as the task and send an HTTP POST to FortiAnalyzer.

    First, start a sniffer on FortiAnalyzer that captures Ethernet data. Using my generic connector, my sniffer looks like this:

    diagnose sniffer packet any ‘host 192.168.1.151 and port 8081’ 3 0 a

    Once that is set up, run the playbook again and look at the output of the sniffer to find the payload, which starts with the meta dictionary.

    FortiAnalyzer sniffer output

    2026-03-23 18:53:44.784960 192.168.1.151.8081 -> 192.168.1.171.57014: psh 154846471 ack 44744771
    0x0000   0000 0000 0001 000c 2931 3adc 0800 4500        ........)1:...E.
    0x0010   05c9 398d 4000 4006 770f c0a8 0197 c0a8        ..9.@.@.w.......
    0x0020   01ab 1f91 deb6 093a c507 02aa c043 5018        .......:.....CP.
    0x0030   0209 ff1a 0000 4461 7465 3a20 4d6f 6e20        ......Date:.Mon.
    0x0040   4d61 7220 3233 2032 3032 3620 3138 3a35        Mar.23.2026.18:5
    0x0050   333a 3434 2047 4d54 2b30 3030 3020 2843        3:44.GMT+0000.(C
    0x0060   6f6f 7264 696e 6174 6564 2055 6e69 7665        oordinated.Unive
    0x0070   7273 616c 2054 696d 6529 0d0a 436f 6e6e        rsal.Time)..Conn
    0x0080   6563 7469 6f6e 3a20 636c 6f73 650d 0a43        ection:.close..C
    0x0090   6f6e 7465 6e74 2d54 7970 653a 2074 6578        ontent-Type:.tex
    0x00a0   742f 706c 6169 6e0d 0a41 6363 6573 732d        t/plain..Access-
    0x00b0   436f 6e74 726f 6c2d 416c 6c6f 772d 4f72        Control-Allow-Or
    0x00c0   6967 696e 3a20 2a0d 0a0d 0a50 4f53 5420        igin:.*....POST.
    0x00d0   2f20 4854 5450 2f31 2e31 0d0a 486f 7374        /.HTTP/1.1..Host
    0x00e0   3a20 3139 322e 3136 382e 312e 3135 313a        :.192.168.1.151:
    0x00f0   3830 3831 0d0a 4163 6365 7074 2d45 6e63        8081..Accept-Enc
    0x0100   6f64 696e 673a 2069 6465 6e74 6974 790d        oding:.identity.
    0x0110   0a55 7365 722d 4167 656e 743a 2070 7974        .User-Agent:.pyt
    0x0120   686f 6e2d 7572 6c6c 6962 332f 322e 332e        hon-urllib3/2.3.
    0x0130   300d 0a43 6f6e 7465 6e74 2d4c 656e 6774        0..Content-Lengt
    0x0140   683a 2031 3136 340d 0a0d 0a7b 226d 6574        h:.1164....{"met
    0x0150   6122 3a20 7b22 6c69 6d69 7422 3a20 3130        a":.{"limit":.10
    0x0160   2c20 226e 6578 7422 3a20 6e75 6c6c 2c20        ,."next":.null,.
    0x0170   226f 6666 7365 7422 3a20 302c 2022 7072        "offset":.0,."pr
    0x0180   6576 696f 7573 223a 206e 756c 6c2c 2022        evious":.null,."
    0x0190   746f 7461 6c5f 636f 756e 7422 3a20 327d        total_count":.2}
    0x01a0   2c20 226f 626a 6563 7473 223a 205b 7b22        ,."objects":.[{"
    0x01b0   6163 7469 7665 223a 2074 7275 652c 2022        active":.true,."
    0x01c0   636f 6d70 616e 7922 3a20 6e75 6c6c 2c20        company":.null,.
    0x01d0   2264 6570 6172 746d 656e 7422 3a20 6e75        "department":.nu
    0x01e0   6c6c 2c20 2264 6e22 3a20 2243 4e3d 6d61        ll,."dn":."CN=ma
    0x01f0   6e61 6765 722c 4f55 3d55 5345 5253 2c4f        nager,OU=USERS,O
    0x0200   553d 4c41 422c 4443 3d61 642c 4443 3d6c        U=LAB,DC=ad,DC=l
    0x0210   6162 646f 6d61 696e 2c44 433d 636f 6d22        abdomain,DC=com"
    0x0220   2c20 2265 6d61 696c 223a 2022 222c 2022        ,."email":."",."
    0x0230   6669 646f 223a 2066 616c 7365 2c20 2266        fido":.false,."f
    0x0240   6972 7374 5f6e 616d 6522 3a20 226d 616e        irst_name":."man
    0x0250   6167 6572 222c 2022 6674 6d5f 6163 745f        ager",."ftm_act_
    0x0260   6d65 7468 6f64 223a 2022 222c 2022 6964        method":."",."id
    0x0270   223a 2032 2c20 2269 735f 6c6f 636b 6564        ":.2,."is_locked
    0x0280   223a 2066 616c 7365 2c20 226c 6173 745f        ":.false,."last_
    0x0290   6e61 6d65 223a 2022 222c 2022 6d6f 6269        name":."",."mobi
    0x02a0   6c65 5f6e 756d 6265 7222 3a20 2222 2c20        le_number":."",.
    0x02b0   2272 6561 736f 6e22 3a20 6e75 6c6c 2c20        "reason":.null,.
    0x02c0   2272 6563 6f76 6572 795f 6279 5f71 7565        "recovery_by_que
    0x02d0   7374 696f 6e22 3a20 6661 6c73 652c 2022        stion":.false,."
    0x02e0   7265 736f 7572 6365 5f75 7269 223a 2022        resource_uri":."
    0x02f0   2f61 7069 2f76 312f 6c64 6170 7573 6572        /api/v1/ldapuser
    0x0300   732f 322f 222c 2022 7365 7276 6572 5f61        s/2/",."server_a
    0x0310   6464 7265 7373 223a 2022 7769 6e2d 6164        ddress":."win-ad
    0x0320   2e61 642e 6c61 6264 6f6d 6169 6e2e 636f        .ad.labdomain.co
    0x0330   6d22 2c20 2273 6572 7665 725f 6e61 6d65        m",."server_name
    0x0340   223a 2022 5749 4e2d 4144 222c 2022 746f        ":."WIN-AD",."to
    0x0350   6b65 6e5f 6175 7468 223a 2066 616c 7365        ken_auth":.false
    0x0360   2c20 2274 6f6b 656e 5f73 6572 6961 6c22        ,."token_serial"
    0x0370   3a20 2222 2c20 2274 6f6b 656e 5f74 7970        :."",."token_typ
    0x0380   6522 3a20 2222 2c20 2275 7365 726e 616d        e":."",."usernam
    0x0390   6522 3a20 226d 616e 6167 6572 4061 642e        e":."manager@ad.
    0x03a0   6c61 6264 6f6d 6169 6e2e 636f 6d22 7d2c        labdomain.com"},
    0x03b0   207b 2261 6374 6976 6522 3a20 6661 6c73        .{"active":.fals
    0x03c0   652c 2022 636f 6d70 616e 7922 3a20 224c        e,."company":."L
    0x03d0   4142 222c 2022 6465 7061 7274 6d65 6e74        AB",."department
    0x03e0   223a 2022 4e65 7453 6563 222c 2022 646e        ":."NetSec",."dn
    0x03f0   223a 2022 434e 3d61 646b 6576 696e 2c4f        ":."CN=adkevin,O
    0x0400   553d 5553 4552 532c 4f55 3d4c 4142 2c44        U=USERS,OU=LAB,D
    0x0410   433d 6164 2c44 433d 6c61 6264 6f6d 6169        C=ad,DC=labdomai
    0x0420   6e2c 4443 3d63 6f6d 222c 2022 656d 6169        n,DC=com",."emai
    0x0430   6c22 3a20 2261 646b 6576 696e 4061 642e        l":."adkevin@ad.
    0x0440   6c61 6264 6f6d 6169 6e2e 636f 6d22 2c20        labdomain.com",.
    0x0450   2266 6964 6f22 3a20 6661 6c73 652c 2022        "fido":.false,."
    0x0460   6669 7273 745f 6e61 6d65 223a 2022 6164        first_name":."ad
    0x0470   6b65 7669 6e22 2c20 2266 746d 5f61 6374        kevin",."ftm_act
    0x0480   5f6d 6574 686f 6422 3a20 2265 6d61 696c        _method":."email
    0x0490   222c 2022 6964 223a 2031 2c20 2269 735f        ",."id":.1,."is_
    0x04a0   6c6f 636b 6564 223a 2066 616c 7365 2c20        locked":.false,.
    0x04b0   226c 6173 745f 6e61 6d65 223a 2022 4c41        "last_name":."LA
    0x04c0   4222 2c20 226d 6f62 696c 655f 6e75 6d62        B",."mobile_numb
    0x04d0   6572 223a 2022 222c 2022 7265 6173 6f6e        er":."",."reason
    0x04e0   223a 2030 2c20 2272 6563 6f76 6572 795f        ":.0,."recovery_
    0x04f0   6279 5f71 7565 7374 696f 6e22 3a20 6661        by_question":.fa
    0x0500   6c73 652c 2022 7265 736f 7572 6365 5f75        lse,."resource_u
    0x0510   7269 223a 2022 2f61 7069 2f76 312f 6c64        ri":."/api/v1/ld
    0x0520   6170 7573 6572 732f 312f 222c 2022 7365        apusers/1/",."se
    0x0530   7276 6572 5f61 6464 7265 7373 223a 2022        rver_address":."
    0x0540   7769 6e2d 6164 2e61 642e 6c61 6264 6f6d        win-ad.ad.labdom
    0x0550   6169 6e2e 636f 6d22 2c20 2273 6572 7665        ain.com",."serve
    0x0560   725f 6e61 6d65 223a 2022 5749 4e2d 4144        r_name":."WIN-AD
    0x0570   222c 2022 746f 6b65 6e5f 6175 7468 223a        ",."token_auth":
    0x0580   2066 616c 7365 2c20 2274 6f6b 656e 5f73        .false,."token_s
    0x0590   6572 6961 6c22 3a20 2222 2c20 2274 6f6b        erial":."",."tok
    0x05a0   656e 5f74 7970 6522 3a20 2222 2c20 2275        en_type":."",."u
    0x05b0   7365 726e 616d 6522 3a20 2261 646b 6576        sername":."adkev
    0x05c0   696e 4061 642e 6c61 6264 6f6d 6169 6e2e        in@ad.labdomain.
    0x05d0   636f 6d22 7d5d 7d                              com"}]}

    Copy the payload from meta to the curly brackets at the end to your text editor of choice (mine is Notepad++) and perform the following find and replace actions:

    1. Replace \r\n (line breaks) with no character using the Extended function
    2. Replace :. (colon dot) with . (dot)
    3. Replace :, (comma dot) with , (comma)

    After that, you should have the entire payload on a single line, and you can, again, paste it into a JSON formatter.

    Here is a short video where I show this process if you want to see it in action:

    With the JSON information available, we can easily find out what information has been retrieved, how it’s structured, and how it can be further used.

    JSON payload

    {
      "meta": {
        "limit": 10,
        "next": null,
        "offset": 0,
        "previous": null,
        "total_count": 2
      },
      "objects": [
        {
          "active": true,
          "company": null,
          "department": null,
          "dn": "CN=manager,OU=USERS,OU=LAB,DC=ad,DC=labdomain,DC=com",
          "email": "",
          "fido": false,
          "first_name": "manager",
          "ftm_act_method": "",
          "id": 2,
          "is_locked": false,
          "last_name": "",
          "mobile_number": "",
          "reason": null,
          "recovery_by_question": false,
          "resource_uri": "/api/v1/ldapusers/2/",
          "server_address": "win-ad.ad.labdomain.com",
          "server_name": "WIN-AD",
          "token_auth": false,
          "token_serial": "",
          "token_type": "",
          "username": "manager@ad.labdomain.com"
        },
        {
          "active": false,
          "company": "LAB",
          "department": "NetSec",
          "dn": "CN=adkevin,OU=USERS,OU=LAB,DC=ad,DC=labdomain,DC=com",
          "email": "adkevin@ad.labdomain.com",
          "fido": false,
          "first_name": "adkevin",
          "ftm_act_method": "email",
          "id": 1,
          "is_locked": false,
          "last_name": "LAB",
          "mobile_number": "",
          "reason": 0,
          "recovery_by_question": false,
          "resource_uri": "/api/v1/ldapusers/1/",
          "server_address": "win-ad.ad.labdomain.com",
          "server_name": "WIN-AD",
          "token_auth": false,
          "token_serial": "",
          "token_type": "",
          "username": "adkevin@ad.labdomain.com"
        }
      ]
    }

    Using my payload, I can get the ID for the user “adkevin@ad.labdomain.com” by accessing objects[1][‘id’].

    With this information, I can now change the Update User Status task accordingly.

    Putting the pieces together

    When you edit a task that includes variables, you have two methods to set variables:

    1. Select them from a selector menu
    2. Writing the variable string yourself

    You can combine these methods by first selecting a variable, clicking on the “A” and then editing the variable as you need it.

    The FAC Update User Status task wants the ID that FAC itself assigned, so if we first select the user_list variable from the previous task, then switch to the manual input, we can now add the information we got previously. This can look like the following:

    FortiAnalyzer task to show how to switch from selector to manual input of variables

    If we now run the playbook again, the user with the ID we got will now be correctly disabled on FAC, as signalled by the red X in Status.

    FortiAuthenticator LDAP user status

    This was for HTTP-based connectors, but I also want to show how you can get variable information to use in FortiGate automation stitches.

    FortiGate automation stitches and FortiAnalyzer

    If a FortiGate logs to FortiAnalyzer and you have configured an automation stitch that uses an Incoming Webhook Call as a trigger, the FortiGate will show up as a connected device in the “FortiOS Connector

    FortiAnalyzer FortiOS connector status

    For this post, I have configured a basic automation stitch that, as an action, creates an address object and adds it to a group. I have this group as the source in a deny policy, and the idea is that if this stitch is triggered, the offending IP will get automatically blocked.

    The trigger, action, and stitch configuration is as follows:

    But we’re getting ahead of ourselves here, because in the action picture, I’m using variables, but how do I even know what they are called and what they represent?

    Setting the stage for automation stitches

    A FortiGate gets the information from FortiAnalyzer in the log variable, which can be accessed using %%log%% in an action. This information is directly available in the GUI by clicking on the % symbol.

    FortiGate automation action help

    This is already a big help, but it doesn’t help with showing you the entire list of variables that FortiAnalyzer makes available to you, so let’s find out how to get this information.

    First, we need to change the action on the FortiGate. By using the %%log%% variable, as shown in the picture above, we can assign this variable a value on FortiAnalyzer with a playbook.

    Second, we need a playbook, and in this case, I have a simple one with two tasks:

    1. Trigger on an event
    2. Execute a FortiOS webhook
    FortiAnalyzer FortiGate playbook

    The trigger matches on the event SSH-GOOGLE, and the event handler simply checks for SSH connections to the Google DNS server.

    The FortiOS webhook task has the information about the FortiGate device where this action gets executed, the webhook name, and the variables, which are taken from the automation action.

    faz_fgt_play_webhook_log

    An important point here is that the variables you use in an action will be dynamically and periodically synced to FortiAnalyzer, meaning that if you change or add variables in an action and wait a bit, FortiAnalyzer will show the updated variables in the task. You see this in two places:

    1. The task itself
    2. By clicking on the “x device(s) connected” message in the FortiOS Connector (see the picture above)

    Here is a comparison of how it looks if we use the variables from the action we will use to create and add an object to a group, and the action we use to get the information for the full information.

    FortiAnalyzer FortiOS connector status with dynamic variables

    The last piece we need, before seeing the values for the variables, is telling FortiAnalyzer what variable to send, and this is done in the FortiOS webhook task.

    In this task, we have, much like with the FAC example from earlier, two ways to accomplish this:

    1. Select the variable from a selector menu
    2. Writing the variable string yourself

    By selecting the variable, you can go through the entire list of available ones, but if you’re not quite sure what you’re looking for, this is tedious, because you’d need as many variables in the FortiGate’s action as there are variables.

    The better method is simply sending everything from the trigger, which is accessed via the variable ${trigger}.

    FortiAnalyzer task variable selection or manual entering

    With this trigger variable set for the FortiGate’s log variable, we can finally get to finding our values, and this is quite simple.

    Getting automation stitch variable values

    On the FortiGate, we are going to set up a debug for the automation stitch application, and there we’ll see what is being delivered by FortiAnalyzer. The necessary commands are as follows:

    diagnose debug application autod -1
    diagnose debug enable

    With this prepared, we can trigger the event handler by trying to connect to Google’s 8.8.8.8 DNS via SSH, and after a bit, the debug messages will come in.

    FortiGate automation stitch log debug

    __action_cli_script_open()-171: cli script action:CREATE-BAD-HOST is called. svc ctx:0x55a1ff3aef80
    accprof:super_admin script:
    %%log%%
    
    __read_cli_script_result()-117: cli script:
    autod.3
     output:
    
    ========== #1, 2026-03-23 19:33:24 ==========
    FGT02  date=2026-03-23 time=19:33:24 eventtime=1774290803211381959 tz="+0100" logid="0100065301" type="event" subtype="system" level="notice" vd="root" logdesc="Internal Message" path="system" name="automation-stitch" action="webhook" mkey="Incoming Webhook Call" log="{ \"adom_name\": \"LAB\", \"adom_prefix\": \"FSFADOM198\", \"handler_name\": \"SSH-GOOGLE\", \"group_value\": \"192.168.1.231::\", \"event_id\": \"202603231000010013\", \"event_time\": \"1774290774\", \"devid\": \"FGVMSLTM26006351\", \"vdom\": \"root\", \"severity\": 2, \"epid\": \"3\", \"epname\": \"192.168.1.231\", \"epip\": \"192.168.1.231\", \"dst_epid\": \"101\", \"dst_epname\": \"8.8.8.8\", \"dst_epip\": \"8.8.8.8\", \"dvid\": \"1062\", \"euid\": \"3\", \"handler_type\": \"basic\", \"rule_name\": \"SSH-GOOGLE\", \"euname\": \"N\\\/A\", \"subject\": \"srcip:192.168.1.231<G>1:6:13<\\\/G>\", \"groupby1\": \"192.168.1.231\", \"logtype\": \"traffic\", \"devtype\": \"FortiGate\", \"extrainfo\": \"{ }\", \"targets\": [ { \"srcip\": \"192.168.1.231\" }
    Unknown action 0
    
    ======= end of #1, 2026-03-23 19:33:24 ======

    And here we got our variable names and their associated values, right for the picking and inserting into our automation action. Now we can change our script in the FortiGate action to create an object using epip in the name and subnet, putting mkey and date in the comment, and adding the object to our group (see above or the debug below for the full script).

    Trigger the event handler again, and everything looks as it should.

    FortiGate automation stitch real debug

    __action_cli_script_open()-171: cli script action:CREATE-BAD-HOST is called. svc ctx:0x55a1ff3be410
    accprof:super_admin script:
    config firewall address
    edit "H_%%log.epip%%"
    set subnet %%log.epip%% 255.255.255.255
    set comment "%%log.mkey%%_%%log.date%%"
    next
    end
    config firewall addrgrp
    edit G_BAD-GROUP
    append member "H_%%log.epip%%"
    next
    end
    
    __read_cli_script_result()-117: cli script:
    autod.2
     output:
    
    ========== #1, 2026-03-23 19:25:33 ==========
    FGT02  config firewall address
    FGT02 (address)  edit "H_192.168.1.231"
    FGT02 (H_192.168.1.231)  set subnet 192.168.1.231 255.255.255.255
    FGT02 (H_192.168.1.231)  set comment "Incoming Webhook Call_2026-03-23"
    FGT02 (H_192.168.1.231)  next
    FGT02 (address)  end
    FGT02  config firewall addrgrp
    FGT02 (addrgrp)  edit G_BAD-GROUP
    FGT02 (G_BAD-GROUP)  append member "H_192.168.1.231"
    FGT02 (G_BAD-GROUP)  next
    FGT02 (addrgrp)  end
    
    ======= end of #1, 2026-03-23 19:25:34 ======
    FortiGate bad group after automation stitch execution

    Wrapping up

    I have uploaded the various Fortinet resources from this post (playbooks, event handler and FortiGate configuration) to a GitHub repository, if you want to look at everything yourself. In the future, I will put all blog-related resources in this repository.

    I did basically everything here before Accelerate 2026, and it helped me become more confident in SecOps tasks, which was why I put this topic on my to-do list in the first place.

    There is definitely more that can be shown regarding this topic, and a better version of this post exists somewhere in a parallel universe, but perfection is something you can strive for; reaching it should never be the goal. My main goal was to cover blind spots that are not mentioned anywhere. I hope this covering of blind posts helps you, dear reader.