Tag: automation

  • Exploring the FortiClient EMS API

    I am not a fan of the official FortiClient EMS API documentation that is available on the Fortinet Developer Network (FNDN). It is a bare-bones documentation that is sparse on explaining how to interact with the API, has very few examples, no responses, lacks a lot of API endpoints, and the endpoints that exist are badly documented (I challenge you to try creating a ZTNA tag with an associated rule using only the documentation).

    To offer a bit of help to you, dear reader, in this regard, I went through the most common configurations when working with EMS, how you accomplish these tasks using the API, and I will also show you how you can help yourself when working with the API.

    The setup

    • FortiGate 70G on 7.6.7
    • FortiClient EMS on 7.4.7
    • 2x Windows 11 client on 25H2 with FortiClient 7.4.7

    All the API examples I give will be done using Python. Everything featured in this post will also be on the Fortinet resources GitHub repository.

    I am using an on-prem EMS. Cloud EMS is a bit different, especially when it comes to the login, and you have to adapt your own scripts accordingly. See the official documentation for Cloud EMS.

    The most important thing: Reverse engineering the API

    I want to give the best advice first, and in this case it’s getting comfortable with reverse engineering API calls.

    In practice, this means that you do something in the GUI and simultaneously use the browser tools to find out what happens, because most actions in the GUI create the exact API call you need to accomplish this in your API tool of choice, be it Python, cURL, or anything of the like.

    Let’s take the example mentioned in the intro: Creating a ZTNA tag with an associated rule

    EMS Security Posture Tag information

    As we can see, I have this ZTNA tag named BROWSER-TAG-NAME, with a user notification message, a comment, and a rule that validates on FortiClient that the user is part of the AD group ZTNA_USERS.

    Once I hit save on this and with my browser tools open and recording, I can see, on Chrome, in the Network tab, a create call. In the Headers tab of this call, I can see the Request URL https://192.168.1.208/api/v1/tags/zero_trust/create and the Request Method of POST.

    Browser tools headers for reverse engineering

    On the Payload tab, after clicking on View Source, I can see the full JSON payload.

    Browser tools payload for reverse engineering

    The Response tab also shows the response, which isn’t that important.

    With these three pieces of information, the URL, method and payload, I can recreate this API call and find out what all the information in the JSON payload means.

    This approach of reverse engineering is, with the current state of the official documentation, invaluable.

    Now let’s get to the meat of this post.

    AI usage disclosure

    There is a function called deep_merge, which I will call out when it is used, that is purely written by Claude Sonnet 4.6. It is used to update a dictionary with new information, also called a deep merge. I did this because it would have taken me too much time to do it myself, and on that day, it was too late, and I just wanted to get this part done.

    Every other piece of code you see in this post is written by me.

    How I structure this

    Just so I don’t have to repeat myself, I will explain how I approach each section concerning the example I give.

    I will start with an explanation of what is being done and some additional information where required, embed the Python script I have made (again, check the GitHub repository if you want to have it all in one place), post the response EMS gives for the relevant API call, and write some more about it, if there is anything to write about.

    All the Python scripts are, hopefully, well commented. Most of the things being done are basic, and only some parts require special attention, which are explained in more detail.

    I am not good at Python, so if you believe any of my scripts are bad, please keep that in mind.

    Logging in, getting your token and logging out.

    Keep in mind that this is for on-prem EMS. Cloud EMS handles logging in differently.

    The first step you need to make when you want to work with the FortiClient EMS API is logging in, and for that, you need an administrator. There is nothing special about this administrator, so on EMS, head to Administration -> Admin Users and add your user with the permissions you need. Setting Trusted Hosts is a good idea.

    Once you log in to the API, you get a Cross-Site Request Forgery (CSRF) token in your cookies. It is important to know that this token is in your cookies; it is not sent in the response you get after logging in.

    This token has to be used for various API endpoints when you create, update or delete information. Endpoints where you only GET information usually don’t need this token.

    After you are done with whatever you need to do using the API, it is common courtesy to perform a logout, where you supply your token.

    Login, get token, logout

    '''
    ems_login_token_logout.py
    Perform a login on the FortiClient EMS API, get the CSRF token and then logout
    '''
    
    import requests
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)

    Login, get token, logout response

    {
       "result":{
          "retval":1,
          "message":"Login successful."
       },
       "data":{
          "login_domain":null,
          "is_password_insecure":false,
          "site":"Default"
       }
    },
    {
       "result":{
          "retval":1,
          "message":"Logout successful."
       }
    }

    Authorizing and editing a FortiGate

    In order for FortiClient EMS to share endpoint information and ZTNA tag information with a FortiGate, the FortiGate needs to first get authorized. After that, you have to decide what tags you want to share and from which endpoints. Often, you want to share Security Posture Tags, and you want to Share All FortiClients, and that is what this script does.

    Authorize and edit FortiGate

    '''
    ems_fgt_authorization_share_clients.py
    Authorize a FortiGate and set properties on it using the FortiClient EMS API
    '''
    
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    fgt_serial = 'FGT70GSERIAL'
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    fgt_authorization_url = f'{api_url_prefix}/client_certificates/set'
    fgt_properties_url = f'{api_url_prefix}/fabric_device_auth/{fgt_serial}/update'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    fgt_authorization_data = {"filters": {"management_mode": "standalone", "cns": [f"{fgt_serial}"]}, "properties": {"authorized": True}}
    
    #share_mode 0 is "Only share FortiClients connected to this fabric device (Recommended)"
    #share_mode 1 is "Share all FortiClients"
    #share_tag_types 1 is "Security Posture Tags", 2 is "Outbreak Tags", 3 is "Classification Tags", 4 is "Fabric Tags"
    fgt_properties_data = {"share_mode":1,"selected_cn_list":[],"share_tag_types":[1],"alias":"FGT-EMSAPI"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Authorize the FortiGate and set properties
    session.patch(url=fgt_authorization_url, json=fgt_authorization_data, headers=change_headers, verify=False, timeout=30)
    session.patch(url=fgt_properties_url, json=fgt_properties_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Authorize and edit FortiGate response

    {
       "result":{
          "retval":1,
          "message":null
       },
       "data":{
          "cns":[
             "FGT70GSERIAL"
          ]
       }
    },
    {
       "result":{
          "retval":1,
          "message":"Fabric device successfully updated."
       }
    }

    Creating a domain import

    Skipping creating an authentication server, eh? Yes. The reason is that creating an authentication server is form-based and I couldn’t find a way to do this using the API, especially when it comes to AD with LDAPS with a certificate upload. Sorry.

    But I know how to create a domain import, which isn’t that easy either, thanks to how EMS handles authentication servers. A short explanation of this.

    EMS collects all authentication servers as Identity Providers (IDPs) in the /api/v1/idps/index endpoint, which you can perform a GET on, but creating it is done via a form on the /api/v1/idps/adfs/create endpoint using a POST.

    Authentication server creation form

    Each IDP has a GUID, and you need this GUID to perform a domain import, because you first have to perform a walk on the directory structure to get the top-level objects (OUs and containers in most cases) and their information (GUID, name, DN and path). If your journey stops there and you just want to import from these top-level objects, you’re done. If you want to import individual groups, for example, in an OU, you have to go down a rabbit hole, because you have to get the GUID of that OU, perform a directory walk on that OU, get the information of the groups therein and get the information for the import (the same as for the OU).

    In an ordered list:

    1. Get IDP/authentication server GUID
    2. Perform a directory walk using the IDP GUID to find top-level objects
    3. Find information on relevant top-level objects 
    4. Optionally, perform a directory walk of top-level objects to find sublevel objects
    5. Optionally, get information on relevant sublevel objects

    The script handles this case, and there is an example directory structure given, and if you reverse engineer the API process, it will start to make more sense than what I have presented here.

    Create domain import

    '''
    ems_create_domain_import.py
    Create a domain import using the FortiClient EMS API
    '''
    
    import urllib.parse
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    idp_name = "ad.labdomain.com"
    
    #Adding groups and OUs to a policy is a bit complicated, so here is an example.
    #Consider the following AD structure:
    
    #ad.labdomain.com
    #└── CLIENTS
    #└── GROUPS
    #   ├── VPN_USERS
    #   ├── ZTNA_USERS
    #└── SERVERS
    #   └── PROD
    
    #If you want to import the entire CLIENTS OU add the name to the assigned_ous list
    #The script will get the required information of the OU and create a dictionary for the import
    
    #If you want to import the VPN_USERS group, and using the structure from above, you first have add the parent OU to the parent_ous list
    #In the example the parent OU is GROUP
    #Then add the VPN_USERS group to the assigned_group_names list
    
    assigned_ous = ['CLIENTS','SERVERS']
    parent_ous = ['GROUPS']
    assigned_group_names = ['VPN_USERS','ZTNA_USERS']
    assigned_ous_groups = []
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    idps_url = f'{api_url_prefix}/idps/index'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get authentication servers data
    response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get all the necessary information directly from the IDP and create the data for the domain import
    for idp in response_decoded['data']:
        if idp['domain_info']['name'] == idp_name:
            idp_id = idp['domain_info']['guid']
            #The DN has to be URL encoded
            idp_dn_urlencoded = urllib.parse.quote(idp['connection_info']['basedn'], safe="")
            domain_import_url = f'{api_url_prefix}/idps/adfs/{idp_id}/patch'
            #The live navigation is used to get the information regarding the directory structure straight from the authentication server
            live_navigation_url = f'{api_url_prefix}/idps/{idp_id}/live_navigate?dn={idp_dn_urlencoded}'
    
            #The response from the live navigation contains the directory structure with all OUs, but not groups
            response = session.get(url=live_navigation_url, headers=api_headers, verify=False, timeout=30)
            response_decoded = json.loads(response.content.decode('utf-8'))
    
            #The two ifs are used to search for the OU name in the assigned OUs, if we want to import an entire OU, and search through the parent OUs if we need to import groups in OUs
            for ou in response_decoded['data']:
                if ou['name'] in assigned_ous:
                    #A temporary dictionary is used to store the information for the to-be-imported object
                    temp_dict = {"guid": ou['guid'], "name": ou['name'], "dn": ou['dn'], "path": ou['canonical_name'], }
                    #The temporary dictionary gets added to a list
                    assigned_ous_groups.append(temp_dict)
                #Much the same is done for groups, except we first have to go through the OUs, like with the initial authentication server
                if ou['name'] in parent_ous:
                    ou_dn_urlencoded = urllib.parse.quote(ou['dn'], safe="")
                    groups_live_navigation_url = f'{api_url_prefix}/idps/{idp_id}/live_navigate?dn={ou_dn_urlencoded}'
                    response = session.get(url=groups_live_navigation_url, headers=api_headers, verify=False, timeout=30)
                    response_decoded = json.loads(response.content.decode('utf-8'))
                    for group in response_decoded['data']:
                        if group['name'] in assigned_group_names:
                            temp_dict = {"guid": group['guid'], "name": group['name'], "dn": group['dn'], "path": group['canonical_name'], }
                            assigned_ous_groups.append(temp_dict)
    
            #With all the dictionaries in the list we can assemble the JSON payload
            domain_data = {
                "sync_mins":60,
                "is_imported": True,
                "selected_group_containers": assigned_ous_groups
            }
    
            #Create domain import
            session.patch(url=domain_import_url, json=domain_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Create domain import result

    {
       "result":{
          "retval":1,
          "message":"IDP updated successfully."
       },
       "data":{
          "guid":"c95b36ba-5adb-480d-b8da-87fc7598d2da",
          "domain_name":"ad.labdomain.com",
          "selected_group_containers":[
             {
                "guid":"d9b2392d-01e5-4e8b-98bf-26fb85a3399c",
                "path":"ad.labdomain.com/CLIENTS",
                "name":"CLIENTS",
                "dn":"OU=CLIENTS,DC=ad,DC=labdomain,DC=com"
             },
             {
                "guid":"a79fcd99-07e2-4c75-a615-58043fd244da",
                "path":"ad.labdomain.com/GROUPS/VPN_USERS",
                "name":"VPN_USERS",
                "dn":"CN=VPN_USERS,OU=GROUPS,DC=ad,DC=labdomain,DC=com"
             },
             {
                "guid":"83658fae-a526-4d9e-8030-8193a7fa0198",
                "path":"ad.labdomain.com/GROUPS/ZTNA_USERS",
                "name":"ZTNA_USERS",
                "dn":"CN=ZTNA_USERS,OU=GROUPS,DC=ad,DC=labdomain,DC=com"
             },
             {
                "guid":"015eb230-26f7-4a10-bde7-df92b0891848",
                "path":"ad.labdomain.com/SERVERS",
                "name":"SERVERS",
                "dn":"OU=SERVERS,DC=ad,DC=labdomain,DC=com"
             }
          ],
          "sync_mins":60
       }
    }

    Getting imported objects

    Not a very important piece, but if you want the information on all the objects your domain import has imported, you can use this script.

    GET imported objects

    '''
    ems_get_imported_ad_objects.py
    Get imported groups of AD server using the FortiClient EMS API
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    idp_name = "ad.labdomain.com"
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    idps_url = f'{api_url_prefix}/idps/index'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get authentication servers data
    response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get imported OUs from IDP
    for idp in response_decoded['data']:
        if idp['domain_info']['name'] == idp_name:
            idp_id = idp['domain_info']['guid']
            idp_ous_url = f'{api_url_prefix}/idps/adfs/{idp_id}/imported_ous'
            response = session.get(url=idp_ous_url, headers=api_headers, verify=False, timeout=30)
            response_decoded = json.loads(response.content.decode('utf-8'))
            print(response_decoded['data']['group_containers'])
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    GET imported objects response

    [
       {
          "id":223,
          "name":"CLIENTS",
          "type":2,
          "dn":"OU=CLIENTS,DC=ad,DC=labdomain,DC=com",
          "is_user_selected":true,
          "guid":"d9b2392d-01e5-4e8b-98bf-26fb85a3399c",
          "full_path":"ad.labdomain.com/CLIENTS",
          "parent_ids":[
             227
          ],
          "has_child":0,
          "blocked":false,
          "policy_id":"None",
          "domain_id":3,
          "policy_name":"None",
          "telemetry_server_list_id":"None",
          "telemetry_server_list_name":"None",
          "total_devices":2,
          "domain_type":1
       },
       {
          "id":224,
          "name":"VPN_USERS",
          "type":4,
          "dn":"CN=VPN_USERS,OU=GROUPS,DC=ad,DC=labdomain,DC=com",
          "is_user_selected":true,
          "guid":"a79fcd99-07e2-4c75-a615-58043fd244da",
          "full_path":"ad.labdomain.com/GROUPS/VPN_USERS",
          "parent_ids":[
             227
          ],
          "has_child":0,
          "blocked":false,
          "policy_id":"None",
          "domain_id":3,
          "policy_name":"None",
          "telemetry_server_list_id":"None",
          "telemetry_server_list_name":"None",
          "total_devices":0,
          "domain_type":1
       },
       {
          "id":225,
          "name":"ZTNA_USERS",
          "type":4,
          "dn":"CN=ZTNA_USERS,OU=GROUPS,DC=ad,DC=labdomain,DC=com",
          "is_user_selected":true,
          "guid":"83658fae-a526-4d9e-8030-8193a7fa0198",
          "full_path":"ad.labdomain.com/GROUPS/ZTNA_USERS",
          "parent_ids":[
             227
          ],
          "has_child":0,
          "blocked":false,
          "policy_id":"None",
          "domain_id":3,
          "policy_name":"None",
          "telemetry_server_list_id":"None",
          "telemetry_server_list_name":"None",
          "total_devices":0,
          "domain_type":1
       },
       {
          "id":226,
          "name":"SERVERS",
          "type":2,
          "dn":"OU=SERVERS,DC=ad,DC=labdomain,DC=com",
          "is_user_selected":true,
          "guid":"015eb230-26f7-4a10-bde7-df92b0891848",
          "full_path":"ad.labdomain.com/SERVERS",
          "parent_ids":[
             227
          ],
          "has_child":1,
          "blocked":false,
          "policy_id":"None",
          "domain_id":3,
          "policy_name":"None",
          "telemetry_server_list_id":"None",
          "telemetry_server_list_name":"None",
          "total_devices":1,
          "domain_type":1
       }
    ]

    Creating a system and remote access profile

    I am only focusing on these two types of profiles because they are probably the most used ones, and going through all of them would take too long. If you want to know how to interact with the other types, you can reverse engineer them.

    Both profile data variables contain most, if not all, options you can set. You can omit options if you want the defaults (most options are already the defaults).

    Create system and remote access profile

    '''
    ems_create_profiles.py
    Create a system and a remote access VPN profile using the FortiClient EMS API
    '''
    
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    temp_password = 'Start123$'
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    system_profile_url = f'{api_url_prefix}/profiles/system/create'
    vpn_profile_url = f'{api_url_prefix}/profiles/vpn/create'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    system_profile_data = {
        "name": "SYS_EMS-API",
        "is_chromebook": False,
        "enabled": True,
        "display_enabled": True,
        "clone_from": None,
        "json": {
            "system": {
                "ui": {
                    "disable_backup": 0,
                    "hide_user_info": 0,
                    "hide_system_tray_icon": 0,
                    "show_host_tag": 1,
                    "password": f"{temp_password}",
                    "lock": f"{temp_password}",
                    "unreg_pwd": f"{temp_password}",
                    "culture_code": "os-default",
                    "default_tab": "VPN",
                    "allow_shutdown_when_registered": 0
                },
                "log_settings": {
                    "onnet_local_logging": 1,
                    "level": 6,
                    "log_events": "antiexploit,antiransomware,av,cloudscan,endpoint,firewall,fssoma,ipsecvpn,pam,sandboxing,sslvpn,update,vuln,webfilter,ztna,configd,scheduler,shield,wanacc",
                    "remote_logging": {
                        "log_upload_enabled": 0,
                        "log_retention_days": 90,
                        "log_upload_freq_minutes": 60,
                        "send_software_inventory": 0,
                        "send_os_events": {
                            "enabled": 1,
                            "interval": 120
                        },
                        "log_upload_server": "",
                        "log_upload_ssl_enabled": 1,
                        "log_generation_timeout_secs": 900,
                        "log_compressed": 0,
                        "netlog_categories": 32
                    }
                },
                "proc_protect": 1,
                "proxy": {
                    "update": 0,
                    "fail_over_to_fdn": 0,
                    "online_scep": 0,
                    "virus_submission": 0,
                    "type": "http",
                    "address": None,
                    "port": "80",
                    "username": None,
                    "password": ""
                },
                "update": {
                    "use_custom_server": 0,
                    "timeout": 60,
                    "failoverport": 8000,
                    "auto_patch": 0,
                    "update_action": "disable",
                    "scheduled_update": {
                        "enabled": 1,
                        "type": "interval",
                        "daily_at": "00:00",
                        "update_interval_in_hours": 1
                    },
                    "submit_virus_info_to_fds": 1,
                    "submit_vuln_info_to_fds": 1,
                    "use_legacy_fdn": 0,
                    "server": "",
                    "port": 80,
                    "fail_over_to_fdn": 0,
                    "restrict_services_to_regions": "",
                    "ocsp_mode": 0
                },
                "fortiproxy": {
                    "enabled": 1,
                    "enable_https_proxy": 1,
                    "http_timeout": 60,
                    "client_comforting": {
                        "pop3_client": 1,
                        "pop3_server": 1,
                        "smtp": 1
                    },
                    "selftest": {
                        "enabled": 1,
                        "last_port": 65535,
                        "notify": 1
                    }
                },
                "certificates": [31],
                "user_identity": {
                    "enable_manually_entering": 0,
                    "enable_linkedin": 0,
                    "enable_google": 0,
                    "enable_salesforce": 0,
                    "notify_user": 0
                },
                "installer": {
                    "allow_admin_uninstall_when_locked": 1
                },
                "cryptography": {
                    "drbg_reseed_minutes": 1440
                }
            },
            "extra": {
                "trigger_vuln_scan": True
            },
            "endpoint_control": {
                "ui": {
                    "hide_compliance_warning": 0
                },
                "notify_fgt_on_logoff": 0,
                "forensics_license": 1,
                "enable_dem": 0,
                "disable_unregister": 1,
                "disable_fgt_switch": 0,
                "show_bubble_notifications": 1,
                "send_software_inventory": 0,
                "invalid_cert_action": "warn",
                "edr_collector": 1,
                "enable_dns_cache": 0,
                "auto_start": 0
            },
            "fssoma": {
                "enabled": 0,
                "serveraddress": "",
                "presharedkey": ""
            },
            "wan_optimization": {
                "enabled": 0,
                "max_disk_cache_size_mb": 512,
                "support_http": 1,
                "support_cifs": 1,
                "support_mapi": 1,
                "support_ftp": 1
            },
            "pam": {
                "enabled": 0,
                "default_port": 9191
            }
        }
    }
    
    vpn_profile_data = {
        "name": "VPN_EMS-API",
        "is_chromebook": False,
        "enabled": True,
        "display_enabled": True,
        "clone_from": None,
        "json": {
            "vpn": {
                "display_vpn": 1,
                "enabled": 1,
                "sslvpn": {
                    "options": {
                        "enabled": 0,
                        "prefer_sslvpn_dns": 1,
                        "disallow_invalid_server_certificate": 0,
                        "warn_invalid_server_certificate": 1,
                        "preferred_dtls_tunnel": 0,
                        "show_auth_cert_only": 0,
                        "use_gui_saml_auth": 0,
                        "block_ipv6": 1,
                        "dnscache_service_control": 0,
                        "no_dns_registration": 0,
                        "negative_split_tunnel_metric": None,
                        "mtu_size": 1300,
                        "dtls_mtu": 1100
                    },
                    "connections": []
                },
                "ipsecvpn": {
                    "options": {
                        "enabled": 1,
                        "use_win_current_user_cert": 1,
                        "use_win_local_computer_cert": 1,
                        "beep_if_error": 0,
                        "usewincert": 1,
                        "usesmcardcert": 1,
                        "use_gui_saml_auth": 0,
                        "block_ipv6": 1,
                        "enable_udp_checksum": 0,
                        "disable_default_route": 0,
                        "show_auth_cert_only": 0,
                        "check_for_cert_private_key": 0,
                        "enhanced_key_usage_mandatory": 0,
                        "disallow_invalid_server_certificate": 0,
                        "prefer_ipsecvpn_dns": 1,
                        "no_dns_registration": 0,
                        "mtu_size": 1280
                    },
                    "connections": [
                        {
                            "name": "API-IPSEC-VPN",
                            "pinned": 0,
                            "dns_priority": 1,
                            "machine": None,
                            "keep_running": 0,
                            "traffic_keep_strategy": 0,
                            "traffic_keep_timer": 5000,
                            "disclaimer_msg": "",
                            "single_user_mode": 0,
                            "ui": {
                                "show_remember_password": 0,
                                "show_alwaysup": 0,
                                "show_autoconnect": 0,
                                "show_passcode": 0,
                                "save_username": 0
                            },
                            "traffic_control": {
                                "enabled": 0,
                                "mode": 1,
                                "apps": [],
                                "fqdns": [],
                                "isdb_objects": [],
                                "vsdb_objects": []
                            },
                            "redundant_sort_method": 0,
                            "tags": {
                                "allowed": "",
                                "prohibited": ""
                            },
                            "host_check_fail_warning": "",
                            "ike_settings": {
                                "server": "192.0.2.1",
                                "authentication_method": "Preshared Key",
                                "auth_data": f"{temp_password}",
                                "transport_mode": 0,
                                "tcp_port": 443,
                                "udp_port": 500,
                                "cert_subjectcheck": 0,
                                "prompt_certificate": 1,
                                "xauth_timeout": 120,
                                "xauth": {
                                    "use_otp": 0,
                                    "enabled": 0,
                                    "prompt_username": 0
                                },
                                "version": 2,
                                "mode": "aggressive",
                                "dhgroup": [31],
                                "key_life": 28800,
                                "localid": None,
                                "networkid": 0,
                                "eap_method": 1,
                                "implied_SPDO": 0,
                                "implied_SPDO_timeout": 60,
                                "nat_traversal": 1,
                                "enable_local_lan": 1,
                                "session_resume": 0,
                                "enable_ike_fragmentation": 1,
                                "mode_config": 1,
                                "modeconfig_type": 0,
                                "dpd": 1,
                                "proposals": [
                                    {
                                        "encryption": "AES128",
                                        "authentication": "SHA256"
                                    },
                                    {
                                        "encryption": "AES256",
                                        "authentication": "SHA256"
                                    }
                                ],
                                "run_fcauth_system": 0,
                                "failover_sslvpn_connection": None,
                                "sso_enabled": 0,
                                "use_external_browser": 0,
                                "ike_saml_port": 443,
                                "keep_fqdn_resolution_consistency": 0,
                                "no_vnic_dns_server": 0,
                                "azure_auto_login": {
                                    "enabled": 0,
                                    "azure_app": {
                                        "tenant_name": "",
                                        "client_id": ""
                                    }
                                }
                            },
                            "ipsec_settings": {
                                "remote_networks": [
                                    {
                                        "addr": "0.0.0.0",
                                        "mask": "0.0.0.0"
                                    },
                                    {
                                        "addr": "::/0",
                                        "mask": "::/0"
                                    }
                                ],
                                "dhgroup": 31,
                                "key_life_type": "seconds",
                                "key_life_seconds": 3600,
                                "key_life_Kbytes": 5200,
                                "replay_detection": 1,
                                "pfs": 1,
                                "virtualip": {
                                    "type": "modeconfig",
                                    "ip": "0.0.0.0",
                                    "mask": "0.0.0.0",
                                    "dnsserver": "0.0.0.0",
                                    "winserver": "0.0.0.0"
                                },
                                "proposals": [
                                    {
                                        "encryption": "AES128GCM",
                                        "authentication": "NONE"
                                    },
                                    {
                                        "encryption": "AES256",
                                        "authentication": "SHA256"
                                    }
                                ],
                                "ipv4_split_exclude_networks": []
                            },
                            "on_connect": [
                                {
                                    "os": "windows",
                                    "script": ""
                                },
                                {
                                    "os": "MacOSX",
                                    "script": ""
                                }
                            ],
                            "on_disconnect": [
                                {
                                    "os": "windows",
                                    "script": ""
                                },
                                {
                                    "os": "MacOSX",
                                    "script": ""
                                }
                            ],
                            "android_cert_path": ""
                        }
                    ]
                },
                "lockdown": {
                    "enabled": 0,
                    "grace_period": 120,
                    "max_attempts": 3,
                    "exceptions": {
                        "apps": None,
                        "ips": None,
                        "domains": None,
                        "icdb_domains": []
                    },
                    "detect_captive_portal": {
                        "enabled": 0,
                        "os_active_probing": 1
                    }
                },
                "options": {
                    "current_connection_name": "",
                    "current_connection_type": None,
                    "autoconnect_tunnel": "",
                    "vendor_id": None,
                    "on_os_start_connect": "",
                    "on_os_start_connect_has_priority": 0,
                    "show_vpn_before_logon": 1,
                    "minimize_window_on_connect": 1,
                    "use_windows_credentials": 0,
                    "suppress_vpn_notification": 0,
                    "secure_remote_access": 0,
                    "certs_require_keyspec": 0,
                    "disable_internet_check": 1,
                    "use_webview2_saml_auth": 0,
                    "enable_multi_vpn": 0,
                    "enforce_disabling_smartdns": 0,
                    "enable_view_selected_vpns": 0,
                    "keep_running_max_tries": 0,
                    "after_logon_saml_auth": 0,
                    "before_logon_saml_auth": 1,
                    "allow_personal_vpns": 1,
                    "disable_connect_disconnect": 0,
                    "autoconnect_on_install": 0,
                    "autoconnect_only_when_offnet": 0,
                    "temp_password": f"{temp_password}"
                }
            }
        }
    }
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Create system profile
    session.post(url=system_profile_url, json=system_profile_data, headers=change_headers, verify=False, timeout=30)
    
    #Create VPN profile
    session.post(url=vpn_profile_url, json=vpn_profile_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Create system and remote access profile response

    {
       "result":{
          "retval":1,
          "message":"Profile component created succesfully."
       },
       "data":{
          "id":68,
          "json":{
             "fssoma":{
                "enabled":0,
                "presharedkey":"",
                "serveraddress":""
             },
             "version":"5.6.0",
             "pam":{
                "enabled":0,
                "default_port":9191
             },
             "endpoint_control":{
                "forensics_license":1,
                "enable_dns_cache":0,
                "enable_dem":0,
                "send_software_inventory":0,
                "disable_fgt_switch":0,
                "auto_start":0,
                "disable_unregister":1,
                "notify_fgt_on_logoff":0,
                "invalid_cert_action":"warn",
                "ui":{
                   "hide_compliance_warning":0
                },
                "show_bubble_notifications":1,
                "edr_collector":1
             },
             "system":{
                "certificates":[
                   
                ],
                "cryptography":{
                   "drbg_reseed_minutes":1440
                },
                "update":{
                   "timeout":60,
                   "auto_patch":0,
                   "fail_over_to_fdn":0,
                   "restrict_services_to_regions":"",
                   "scheduled_update":{
                      "type":"interval",
                      "enabled":1,
                      "daily_at":"00:00",
                      "update_interval_in_hours":1
                   },
                   "submit_virus_info_to_fds":1,
                   "port":80,
                   "update_action":"disable",
                   "failoverport":8000,
                   "use_legacy_fdn":0,
                   "ocsp_mode":0,
                   "submit_vuln_info_to_fds":1,
                   "server":"",
                   "use_custom_server":0
                },
                "proxy":{
                   "username":null,
                   "update":0,
                   "fail_over_to_fdn":0,
                   "port":80,
                   "virus_submission":0,
                   "type":"http",
                   "password":"",
                   "address":null,
                   "online_scep":0
                },
                "installer":{
                   "allow_admin_uninstall_when_locked":1
                },
                "proc_protect":1,
                "user_identity":{
                   "enable_google":0,
                   "enable_linkedin":0,
                   "enable_manually_entering":0,
                   "notify_user":0,
                   "enable_salesforce":0
                },
                "log_settings":{
                   "onnet_local_logging":1,
                   "level":6,
                   "log_events":"antiexploit,antiransomware,av,cloudscan,endpoint,firewall,fssoma,ipsecvpn,pam,sandboxing,sslvpn,update,vuln,webfilter,ztna,configd,scheduler,shield,wanacc",
                   "remote_logging":{
                      "send_os_events":{
                         "enabled":1,
                         "interval":120
                      },
                      "log_upload_enabled":0,
                      "log_retention_days":90,
                      "log_upload_ssl_enabled":1,
                      "send_software_inventory":0,
                      "log_upload_server":"",
                      "log_generation_timeout_secs":900,
                      "netlog_categories":32,
                      "log_upload_freq_minutes":60,
                      "log_compressed":0
                   }
                },
                "fortiproxy":{
                   "http_timeout":60,
                   "selftest":{
                      "notify":1,
                      "enabled":1,
                      "last_port":65535
                   },
                   "client_comforting":{
                      "pop3_client":1,
                      "pop3_server":1,
                      "smtp":1
                   },
                   "enable_https_proxy":1,
                   "enabled":1
                },
                "ui":{
                   "allow_shutdown_when_registered":0,
                   "disable_backup":0,
                   "unreg_pwd":"QyCr5xV6oIlvsBXpKY0WOZBgz7m3EtM4lYbtuuNudjSPW4meHcM6EHXKBb1XzeMcW4yVBnnVmBCcW2EDcwP7GevjLB27aE1Ht6PHPIEa0PzI1HdVrB8M6wewPN4B91Jb$OXwDzKLMons/0ycr3vyNBQQ53CCqovZM3N7geRo1J9wyGYGDehQ/pwjRehPmKcmhUfcchXh/83ucpm+uX7BONA==",
                   "lock":"Enc 94150c56da89b13d9645ed3cd5928f88764097e7f638f57aebdf9e07050cddc07d0a71024d30b4a834e32df39b63c2ca1b7516e79895cfda4556f2dd7a8151507d59b62b3e9dabdfdc7fb1dae92467fcc94b05106769a7ba",
                   "password":"Enc fee42e05beef862d7dd22b0e497534ebcc6840941f7ffdc6567519915361f10e87043d14c05c140e4ab9e49ee7a6328f53ee8ea9aed16eb8",
                   "culture_code":"os-default",
                   "hide_user_info":0,
                   "default_tab":"VPN",
                   "hide_system_tray_icon":0,
                   "show_host_tag":1
                }
             },
             "wan_optimization":{
                "support_cifs":1,
                "support_http":1,
                "support_ftp":1,
                "enabled":0,
                "support_mapi":1,
                "max_disk_cache_size_mb":512
             }
          }
       }
    },
    {
       "result":{
          "retval":1,
          "message":"Profile component created succesfully."
       },
       "data":{
          "id":69,
          "json":{
             "vpn":{
                "sslvpn":{
                   "connections":[
                      
                   ],
                   "options":{
                      "dtls_mtu":1100,
                      "no_dns_registration":0,
                      "show_auth_cert_only":0,
                      "disallow_invalid_server_certificate":0,
                      "use_gui_saml_auth":0,
                      "warn_invalid_server_certificate":1,
                      "block_ipv6":1,
                      "negative_split_tunnel_metric":null,
                      "preferred_dtls_tunnel":0,
                      "mtu_size":1300,
                      "dnscache_service_control":0,
                      "enabled":0,
                      "prefer_sslvpn_dns":1
                   }
                },
                "ipsecvpn":{
                   "connections":[
                      {
                         "name":"API-IPSEC-VPN",
                         "pinned":0,
                         "dns_priority":1,
                         "machine":0,
                         "keep_running":0,
                         "traffic_keep_strategy":0,
                         "traffic_keep_timer":5000,
                         "disclaimer_msg":"",
                         "single_user_mode":0,
                         "ui":{
                            "show_remember_password":0,
                            "show_alwaysup":0,
                            "show_autoconnect":0,
                            "show_passcode":0,
                            "save_username":0
                         },
                         "traffic_control":{
                            "enabled":0,
                            "mode":1,
                            "apps":[
                               
                            ],
                            "fqdns":[
                               
                            ],
                            "isdb_objects":[
                               
                            ],
                            "vsdb_objects":[
                               
                            ]
                         },
                         "redundant_sort_method":0,
                         "tags":{
                            "allowed":"",
                            "prohibited":""
                         },
                         "host_check_fail_warning":"",
                         "ike_settings":{
                            "server":"192.0.2.1",
                            "authentication_method":"Preshared Key",
                            "auth_data":"Enc 564234f7087b4c8c602610fb456a4af44209e584441c59506e86588d8d018f97c1",
                            "transport_mode":0,
                            "tcp_port":443,
                            "udp_port":500,
                            "cert_subjectcheck":0,
                            "prompt_certificate":0,
                            "xauth_timeout":120,
                            "xauth":{
                               "use_otp":0,
                               "enabled":0,
                               "prompt_username":0,
                               "username":"",
                               "password":""
                            },
                            "version":2,
                            "mode":"aggressive",
                            "dhgroup":[
                               31
                            ],
                            "key_life":28800,
                            "localid":"",
                            "networkid":0,
                            "eap_method":1,
                            "implied_SPDO":0,
                            "implied_SPDO_timeout":60,
                            "nat_traversal":1,
                            "enable_local_lan":1,
                            "session_resume":0,
                            "enable_ike_fragmentation":1,
                            "mode_config":1,
                            "modeconfig_type":0,
                            "dpd":1,
                            "proposals":[
                               {
                                  "encryption":"AES128",
                                  "authentication":"SHA256"
                               },
                               {
                                  "encryption":"AES256",
                                  "authentication":"SHA256"
                               }
                            ],
                            "run_fcauth_system":0,
                            "failover_sslvpn_connection":"",
                            "sso_enabled":0,
                            "use_external_browser":0,
                            "ike_saml_port":443,
                            "keep_fqdn_resolution_consistency":0,
                            "no_vnic_dns_server":0,
                            "azure_auto_login":{
                               "enabled":0,
                               "azure_app":{
                                  "tenant_name":"",
                                  "client_id":""
                               }
                            },
                            "fgt":1,
                            "dpd_retry_count":3,
                            "dpd_retry_interval":20,
                            "certificate":null,
                            "nat_alive_freq":10
                         },
                         "ipsec_settings":{
                            "remote_networks":[
                               {
                                  "addr":"0.0.0.0",
                                  "mask":"0.0.0.0"
                               },
                               {
                                  "addr":"::/0",
                                  "mask":"::/0"
                               }
                            ],
                            "dhgroup":31,
                            "key_life_type":"seconds",
                            "key_life_seconds":3600,
                            "key_life_Kbytes":5200,
                            "replay_detection":1,
                            "pfs":1,
                            "virtualip":{
                               "type":"modeconfig",
                               "ip":"0.0.0.0",
                               "mask":"0.0.0.0",
                               "dnsserver":"0.0.0.0",
                               "winserver":"0.0.0.0"
                            },
                            "proposals":[
                               {
                                  "encryption":"AES128GCM",
                                  "authentication":"NONE"
                               },
                               {
                                  "encryption":"AES256",
                                  "authentication":"SHA256"
                               }
                            ],
                            "ipv4_split_exclude_networks":[
                               
                            ],
                            "use_vip":1
                         },
                         "on_connect":[
                            {
                               "os":"windows",
                               "script":""
                            },
                            {
                               "os":"MacOSX",
                               "script":""
                            }
                         ],
                         "on_disconnect":[
                            {
                               "os":"windows",
                               "script":""
                            },
                            {
                               "os":"MacOSX",
                               "script":""
                            }
                         ],
                         "android_cert_path":"",
                         "uid":"00C4F2CA-5814-4612-8F26-3838D8746782",
                         "warn_invalid_server_certificate":1,
                         "type":"manual"
                      }
                   ],
                   "options":{
                      "disable_default_route":0,
                      "block_ipv6":1,
                      "use_win_local_computer_cert":1,
                      "check_for_cert_private_key":0,
                      "mtu_size":1280,
                      "usesmcardcert":1,
                      "beep_if_error":0,
                      "enhanced_key_usage_mandatory":0,
                      "no_dns_registration":0,
                      "show_auth_cert_only":0,
                      "disallow_invalid_server_certificate":0,
                      "prefer_ipsecvpn_dns":1,
                      "use_gui_saml_auth":0,
                      "use_win_current_user_cert":1,
                      "enable_udp_checksum":0,
                      "usewincert":1,
                      "enabled":1
                   }
                },
                "enabled":1,
                "lockdown":{
                   "grace_period":120,
                   "max_attempts":3,
                   "detect_captive_portal":{
                      "enabled":0,
                      "os_active_probing":1
                   },
                   "exceptions":{
                      "domains":[
                         
                      ],
                      "ips":[
                         
                      ],
                      "apps":[
                         
                      ],
                      "icdb_domains":[
                         
                      ]
                   },
                   "enabled":0
                },
                "options":{
                   "after_logon_saml_auth":0,
                   "temp_password":"Start123$",
                   "enable_view_selected_vpns":0,
                   "minimize_window_on_connect":1,
                   "enable_multi_vpn":0,
                   "autoconnect_tunnel":"",
                   "show_vpn_before_logon":1,
                   "on_os_start_connect":"",
                   "secure_remote_access":0,
                   "current_connection_type":"",
                   "certs_require_keyspec":0,
                   "disable_internet_check":1,
                   "autoconnect_on_install":0,
                   "allow_personal_vpns":1,
                   "disconnect_password":"",
                   "vendor_id":"",
                   "use_windows_credentials":0,
                   "autoconnect_only_when_offnet":0,
                   "current_connection_name":"",
                   "before_logon_saml_auth":1,
                   "disable_connect_disconnect":0,
                   "on_os_start_connect_has_priority":0,
                   "suppress_vpn_notification":0,
                   "use_webview2_saml_auth":0,
                   "keep_running_max_tries":0,
                   "enforce_disabling_smartdns":0
                },
                "display_vpn":1
             }
          }
       }
    }

    Updating a system and remote access profile

    When it comes to updating things in EMS, you have to be careful because you often cannot just do a PATCH. Most endpoints for updating use a PUT, and when you do that, you are only setting the options you supply, and for everything else, the defaults are used.

    In the example script, I disable security postage tags on the FortiClient GUI and disable bubble notifications in the system settings profile. If I were to send just this to the /api/v1/profiles/system/{ID}/update endpoint, it would set these two things, but it would set everything else to the default values. I handle this case by first performing a GET on the existing profile, saving the information in a variable and performing a deep merge with the updated values. The resulting variable, including the old and updated information, then gets used for the PUT.

    This update procedure is also where the deep_merge function is used, which Claude has written.

    Update system and remote access profile using deep merge

    '''
    ems_update_profiles_deep_merge.py
    Update a system and a remote access VPN profile by first importing the existing configuration using the FortiClient EMS API
    
    This script updates the system settings profile created in ems_create_profiles.py by disabling security posting tags on the GUI and disabling bubble notifications
    This script updates the VPN profile created in ems_create_profiles.py by
    * Enabling the save username option
    * Changing the remote gateway
    * Changing the phase 1 DH group
    * Enabling session resume
    * Changing the phase 2 proposals
    * Disabling personal VPNs
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    def deep_merge(base, override):
        """
        Function written by Claude Sonnet 4.6
    
        Recursively merge `override` into `base`.
        - Dicts: merged recursively.
        - Lists of dicts with a 'name' key: matched by name, then merged recursively.
        - Everything else: override replaces base.
        """
        if isinstance(base, dict) and isinstance(override, dict):
            result = base.copy()
            for key, override_val in override.items():
                base_val = result.get(key)
                result[key] = deep_merge(base_val, override_val)
            return result
    
        if (
            isinstance(base, list)
            and isinstance(override, list)
            and all(isinstance(i, dict) and "name" in i for i in base + override)
        ):
            # Match connection objects by 'name', merge matched pairs
            base_by_name = {item["name"]: item for item in base}
            result = []
            for override_item in override:
                name = override_item["name"]
                if name in base_by_name:
                    result.append(deep_merge(base_by_name[name], override_item))
                else:
                    result.append(override_item)  # new entry, add as-is
            # Preserve base entries not present in override
            override_names = {item["name"] for item in override}
            for base_item in base:
                if base_item["name"] not in override_names:
                    result.append(base_item)
            return result
    
        # Scalar, plain list, or mismatched types: override wins
        return override
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    temp_password = 'Start123$'
    system_profile_name = "SYS_EMS-API"
    vpn_profile_name = "VPN_EMS-API"
    vpn_ipsec_connection_name = "API-IPSEC-VPN"
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
    vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    updated_system_profile_data = {
        "json": {
            "system": {
                "ui": {
                    "show_host_tag": 0,
                }
            },
            "endpoint_control": {
                "show_bubble_notifications": 0,
            }
        }
    }
    
    updated_vpn_profile_data = {
        "name": f"{vpn_profile_name}",
        "json": {
            "vpn": {
                "ipsecvpn": {
                    "connections": [
                        {
                            "name": f"{vpn_ipsec_connection_name}",
                            "ui": {
                                "save_username": 1
                            },
                            "ike_settings": {
                                "server": "192.0.2.254",
                                "dhgroup": [21],
                                "session_resume": 1,
                            },
                            "ipsec_settings": {
                                "proposals": [
                                    {
                                        "encryption": "AES256GCM",
                                        "authentication": "NONE"
                                    },
                                    {
                                        "encryption": "AES256",
                                        "authentication": "SHA512"
                                    }
                                ]
                            },
                        }
                    ]
                },
                "options": {
                    "allow_personal_vpns": 0,
                }
            }
        }
    }
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get system profiles
    response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for profile in response_decoded['data']['local']:
        if profile['name'] == system_profile_name:
            system_profile_get_url = f'{api_url_prefix}/profiles/system/{profile["id"]}/get'
            response = session.get(url=system_profile_get_url, headers=api_headers, verify=False, timeout=30)
            response_decoded = json.loads(response.content.decode('utf-8'))
            system_profile_data = response_decoded['data']
    
            #Update system_profile_data with the updated information
            system_profile_data = deep_merge(system_profile_data, updated_system_profile_data)
    
            #Set the correct URL for updating the system profile using the profile ID
            system_profile_update_url = f'{api_url_prefix}/profiles/system/{profile["id"]}/update'
            #Update system profile
            session.put(url=system_profile_update_url, json=system_profile_data, headers=change_headers, verify=False, timeout=30)
    
    #Get VPN profiles
    response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for profile in response_decoded['data']['local']:
        if profile['name'] == vpn_profile_name:
            vpn_profile_get_url = f'{api_url_prefix}/profiles/vpn/{profile["id"]}/get'
            response = session.get(url=vpn_profile_get_url, headers=api_headers, verify=False, timeout=30)
            response_decoded = json.loads(response.content.decode('utf-8'))
            vpn_profile_data = response_decoded['data']
    
            #Update vpn_profile_data with the updated information
            #update_dictionary(vpn_profile_data['json']['vpn'], updated_vpn_profile_data['json']['vpn'])
            vpn_profile_data = deep_merge(vpn_profile_data, updated_vpn_profile_data)
    
            #Set the correct URL for updating the VPN profile using the profile ID
            vpn_profile_update_url = f'{api_url_prefix}/profiles/vpn/{profile["id"]}/update'
            #Update VPN profile
            session.put(url=vpn_profile_update_url, json=vpn_profile_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Update system and remote access profile using deep merge response

    {
       "result":{
          "retval":1,
          "message":"Profile component updated succesfully."
       },
       "data":{
          "id":70,
          "json":{
             "fssoma":{
                "enabled":0,
                "presharedkey":"",
                "serveraddress":""
             },
             "version":"5.6.0",
             "pam":{
                "enabled":0,
                "default_port":9191
             },
             "endpoint_control":{
                "forensics_license":1,
                "enable_dns_cache":0,
                "enable_dem":0,
                "send_software_inventory":0,
                "disable_fgt_switch":0,
                "auto_start":0,
                "disable_unregister":1,
                "notify_fgt_on_logoff":0,
                "invalid_cert_action":"warn",
                "ui":{
                   "hide_compliance_warning":0
                },
                "show_bubble_notifications":0,
                "edr_collector":1
             },
             "system":{
                "certificates":[
                   
                ],
                "cryptography":{
                   "drbg_reseed_minutes":1440
                },
                "update":{
                   "timeout":60,
                   "auto_patch":0,
                   "fail_over_to_fdn":0,
                   "restrict_services_to_regions":"",
                   "scheduled_update":{
                      "type":"interval",
                      "enabled":1,
                      "daily_at":"00:00",
                      "update_interval_in_hours":1
                   },
                   "submit_virus_info_to_fds":1,
                   "port":80,
                   "update_action":"disable",
                   "failoverport":8000,
                   "use_legacy_fdn":0,
                   "ocsp_mode":0,
                   "submit_vuln_info_to_fds":1,
                   "server":"",
                   "use_custom_server":0
                },
                "proxy":{
                   "username":null,
                   "update":0,
                   "fail_over_to_fdn":0,
                   "port":80,
                   "virus_submission":0,
                   "type":"http",
                   "password":"",
                   "address":null,
                   "online_scep":0
                },
                "installer":{
                   "allow_admin_uninstall_when_locked":1
                },
                "proc_protect":1,
                "user_identity":{
                   "enable_google":0,
                   "enable_linkedin":0,
                   "enable_manually_entering":0,
                   "notify_user":0,
                   "enable_salesforce":0
                },
                "log_settings":{
                   "onnet_local_logging":1,
                   "level":6,
                   "log_events":"antiexploit,antiransomware,av,cloudscan,endpoint,firewall,fssoma,ipsecvpn,pam,sandboxing,sslvpn,update,vuln,webfilter,ztna,configd,scheduler,shield,wanacc",
                   "remote_logging":{
                      "send_os_events":{
                         "enabled":1,
                         "interval":120
                      },
                      "log_upload_enabled":0,
                      "log_retention_days":90,
                      "log_upload_ssl_enabled":1,
                      "send_software_inventory":0,
                      "log_upload_server":"",
                      "log_generation_timeout_secs":900,
                      "netlog_categories":32,
                      "log_upload_freq_minutes":60,
                      "log_compressed":0
                   }
                },
                "fortiproxy":{
                   "http_timeout":60,
                   "selftest":{
                      "notify":1,
                      "enabled":1,
                      "last_port":65535
                   },
                   "client_comforting":{
                      "pop3_client":1,
                      "pop3_server":1,
                      "smtp":1
                   },
                   "enable_https_proxy":1,
                   "enabled":1
                },
                "ui":{
                   "allow_shutdown_when_registered":0,
                   "disable_backup":0,
                   "unreg_pwd":"goBsfMBfsTRbI3732fJADZ2gJEqSFB7zeBKnNb2U3WRDVgKla5vP6XG4xPpK85mDIpq2xSvSfyagMMdQ7Hl73WiOMntt5LdOOgUvUi0MT8Oq74vif2XsiFd3tf57SFV7$TQJ1qTiQelcT/qoHkN4laeQI6jvKLeoe+1WevZOq9oBktCLyBB4goInKHqF9jiPGDNhrT7rToVWNEXKeqF8myQ==",
                   "lock":"Enc 283431078f7ac0acbb5fd257d3279947f5cd01bbe523458b9d50b1a1d5a8bf373b784abec45318e4646034cf13d32e00df29a194c3a247b9db53f6e768362e37239b7005d02266424b0efd63aac9c86669a86c0a0368caa7",
                   "password":"Enc 68be3193bbd992ab167dd677ba394598d6c827dcedc1d91d842296f9fcfb9cc97b6d62fc1e38124e0b1ea9dc2233665e1a76f192808a1f09",
                   "culture_code":"os-default",
                   "hide_user_info":0,
                   "default_tab":"VPN",
                   "hide_system_tray_icon":0,
                   "show_host_tag":0
                }
             },
             "wan_optimization":{
                "support_cifs":1,
                "support_http":1,
                "support_ftp":1,
                "enabled":0,
                "support_mapi":1,
                "max_disk_cache_size_mb":512
             }
          }
       }
    },
    {
       "result":{
          "retval":1,
          "message":"Profile component updated succesfully."
       },
       "data":{
          "id":70,
          "json":{
             "vpn":{
                "sslvpn":{
                   "connections":[
                      
                   ],
                   "options":{
                      "dtls_mtu":1100,
                      "no_dns_registration":0,
                      "show_auth_cert_only":0,
                      "disallow_invalid_server_certificate":0,
                      "use_gui_saml_auth":0,
                      "warn_invalid_server_certificate":1,
                      "block_ipv6":1,
                      "negative_split_tunnel_metric":null,
                      "preferred_dtls_tunnel":0,
                      "mtu_size":1300,
                      "dnscache_service_control":0,
                      "enabled":0,
                      "prefer_sslvpn_dns":1
                   }
                },
                "ipsecvpn":{
                   "connections":[
                      {
                         "name":"API-IPSEC-VPN",
                         "pinned":0,
                         "dns_priority":1,
                         "machine":0,
                         "keep_running":0,
                         "traffic_keep_strategy":0,
                         "traffic_keep_timer":5000,
                         "disclaimer_msg":"",
                         "single_user_mode":0,
                         "ui":{
                            "show_remember_password":0,
                            "show_alwaysup":0,
                            "show_autoconnect":0,
                            "show_passcode":0,
                            "save_username":1
                         },
                         "traffic_control":{
                            "enabled":0,
                            "mode":1,
                            "apps":[
                               
                            ],
                            "fqdns":[
                               
                            ],
                            "isdb_objects":[
                               
                            ],
                            "vsdb_objects":[
                               
                            ]
                         },
                         "redundant_sort_method":0,
                         "tags":{
                            "allowed":"",
                            "prohibited":""
                         },
                         "host_check_fail_warning":"",
                         "ike_settings":{
                            "server":"192.0.2.254",
                            "authentication_method":"Preshared Key",
                            "auth_data":"Enc f7446dfccd809fbf6486f27f81c515eafa55049574aae9672f18533318cdb297e0",
                            "transport_mode":0,
                            "tcp_port":443,
                            "udp_port":500,
                            "cert_subjectcheck":0,
                            "prompt_certificate":0,
                            "xauth_timeout":120,
                            "xauth":{
                               "use_otp":0,
                               "enabled":0,
                               "prompt_username":0,
                               "username":"",
                               "password":""
                            },
                            "version":2,
                            "mode":"aggressive",
                            "dhgroup":[
                               21
                            ],
                            "key_life":28800,
                            "localid":"",
                            "networkid":0,
                            "eap_method":1,
                            "implied_SPDO":0,
                            "implied_SPDO_timeout":60,
                            "nat_traversal":1,
                            "enable_local_lan":1,
                            "session_resume":1,
                            "enable_ike_fragmentation":1,
                            "mode_config":1,
                            "modeconfig_type":0,
                            "dpd":1,
                            "proposals":[
                               {
                                  "encryption":"AES128",
                                  "authentication":"SHA256"
                               },
                               {
                                  "encryption":"AES256",
                                  "authentication":"SHA256"
                               }
                            ],
                            "run_fcauth_system":0,
                            "failover_sslvpn_connection":"",
                            "sso_enabled":0,
                            "use_external_browser":0,
                            "ike_saml_port":443,
                            "keep_fqdn_resolution_consistency":0,
                            "no_vnic_dns_server":0,
                            "azure_auto_login":{
                               "enabled":0,
                               "azure_app":{
                                  "tenant_name":"",
                                  "client_id":""
                               }
                            },
                            "fgt":1,
                            "dpd_retry_count":3,
                            "dpd_retry_interval":20,
                            "certificate":null,
                            "nat_alive_freq":10
                         },
                         "ipsec_settings":{
                            "remote_networks":[
                               {
                                  "addr":"0.0.0.0",
                                  "mask":"0.0.0.0"
                               },
                               {
                                  "addr":"::/0",
                                  "mask":"::/0"
                               }
                            ],
                            "dhgroup":31,
                            "key_life_type":"seconds",
                            "key_life_seconds":3600,
                            "key_life_Kbytes":5200,
                            "replay_detection":1,
                            "pfs":1,
                            "virtualip":{
                               "type":"modeconfig",
                               "ip":"0.0.0.0",
                               "mask":"0.0.0.0",
                               "dnsserver":"0.0.0.0",
                               "winserver":"0.0.0.0"
                            },
                            "proposals":[
                               {
                                  "encryption":"AES256GCM",
                                  "authentication":"NONE"
                               },
                               {
                                  "encryption":"AES256",
                                  "authentication":"SHA512"
                               }
                            ],
                            "ipv4_split_exclude_networks":[
                               
                            ],
                            "use_vip":1
                         },
                         "on_connect":[
                            {
                               "os":"windows",
                               "script":""
                            },
                            {
                               "os":"MacOSX",
                               "script":""
                            }
                         ],
                         "on_disconnect":[
                            {
                               "os":"windows",
                               "script":""
                            },
                            {
                               "os":"MacOSX",
                               "script":""
                            }
                         ],
                         "android_cert_path":"",
                         "uid":"B0A6E39F-F101-46DC-B94E-2F42A375ECF7",
                         "warn_invalid_server_certificate":1,
                         "type":"manual"
                      }
                   ],
                   "options":{
                      "disable_default_route":0,
                      "block_ipv6":1,
                      "use_win_local_computer_cert":1,
                      "check_for_cert_private_key":0,
                      "mtu_size":1280,
                      "usesmcardcert":1,
                      "beep_if_error":0,
                      "enhanced_key_usage_mandatory":0,
                      "no_dns_registration":0,
                      "show_auth_cert_only":0,
                      "disallow_invalid_server_certificate":0,
                      "prefer_ipsecvpn_dns":1,
                      "use_gui_saml_auth":0,
                      "use_win_current_user_cert":1,
                      "enable_udp_checksum":0,
                      "usewincert":1,
                      "enabled":1
                   }
                },
                "enabled":1,
                "lockdown":{
                   "grace_period":120,
                   "max_attempts":3,
                   "detect_captive_portal":{
                      "enabled":0,
                      "os_active_probing":1
                   },
                   "exceptions":{
                      "domains":[
                         
                      ],
                      "ips":[
                         
                      ],
                      "apps":[
                         
                      ],
                      "icdb_domains":[
                         
                      ]
                   },
                   "enabled":0
                },
                "options":{
                   "after_logon_saml_auth":0,
                   "temp_password":"Start123$",
                   "enable_view_selected_vpns":0,
                   "minimize_window_on_connect":1,
                   "enable_multi_vpn":0,
                   "autoconnect_tunnel":"",
                   "show_vpn_before_logon":1,
                   "on_os_start_connect":"",
                   "secure_remote_access":0,
                   "current_connection_type":"",
                   "certs_require_keyspec":0,
                   "disable_internet_check":1,
                   "autoconnect_on_install":0,
                   "allow_personal_vpns":0,
                   "disconnect_password":"",
                   "vendor_id":"",
                   "use_windows_credentials":0,
                   "autoconnect_only_when_offnet":0,
                   "current_connection_name":"",
                   "before_logon_saml_auth":1,
                   "disable_connect_disconnect":0,
                   "on_os_start_connect_has_priority":0,
                   "suppress_vpn_notification":0,
                   "use_webview2_saml_auth":0,
                   "keep_running_max_tries":0,
                   "enforce_disabling_smartdns":0
                },
                "display_vpn":1
             }
          }
       }
    }

    In the GitHub repository, there is also a script called ems_update_profiles_entire_data.py, which has the entire data as a variable and sends it without first copying the existing profile. This is an alternative and is almost the same as creating a profile, except it is still an update.

    Creating security posture/ZTNA tags and rules

    This is the one that, I believe, is impossible to do with the official documentation, because the documentation, when it comes to rules, just says what type of values (string, integer or boolean) you have to supply, but it is not possible to know what any value should actually be. Reverse engineering luckily solves this problem.

    If you want to employ custom logic for rules, good luck with only the documentation, because all you get is string for what you need to supply.

    Both these cases are covered in the script, and there is some additional information in the comments regarding a few keys you use in rules.

    Create security posture/ZTNA tag and rules

    '''
    ems_create_ztna_tag_rule.py
    Create a ZTNA tag and associated rule using the FortiClient EMS API
    '''
    
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    ztna_url = f'{api_url_prefix}/tags/zero_trust/create'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #fct_based means the rule is evaluated on FortiClient directly, not on EMS
    #The id value in the rules key must be incremented for each rule of a tag
    #The type should be the position of the option in the GUI, meaning that the first element of the dropdown is 1, the second 2, etc.
    #Not all types where checked if this rule of numbering holds true and this can change in the future
    #The os number is in the order in the GUI from left to right, starting at 1 for Windows
    #The content is completely dependent on the type of rule used, but most often corresponds to whatever you would enter in a field or what you can select
    ztna_data = {
                    "name":"ZTNA-TAG_API",
                    "description":"This is the User Notification Message",
                    "status": True,
                    "comments":"Created using the API",
                    "rules":[
                        {
                            "negative": False,
                            "content":"GROUPS/ZTNA_USERS",
                            "domainName":"ad.labdomain.com",
                            "type":1,
                            "os":1,
                            "id":1,
                            "fct_based":True
                        },
                        {
                            "negative": False,
                            "content":"C:\\temp\\file1.txt",
                            "context":"",
                            "type":4,
                            "os":1,
                            "id":2,
                        }
                    ],
                    "use_custom_logic": False
                }
    
    ztna_data_custom_logic = {
                    "name":"ZTNA-TAG-CUSTOM-LOGIC_API",
                    "description":"This is the User Notification Message",
                    "status": True,
                    "comments":"Created using the API with custom logic",
                    "rules":[
                        {
                            "negative": False,
                            "content":"GROUPS/ZTNA_USERS",
                            "domainName":"ad.labdomain.com",
                            "type":1,
                            "os":1,
                            "id":1,
                            "fct_based":True
                        },
                        {
                            "negative": False,
                            "content":"C:\\temp\\file1.txt",
                            "context":"",
                            "type":4,
                            "os":1,
                            "id":2,
                        }
                    ],
                    "use_custom_logic": True,
                        "logic":{
                            "android": None,
                            "ios": None,
                            "linux": None,
                            "mac": None,
                            "windows":"{\"op\":\"or\",\"rules\":[{\"id\":1},{\"id\":2}]}"
                        }
                }
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Create ZTNA tag and rule
    session.post(url=ztna_url, json=ztna_data, headers=change_headers, verify=False, timeout=30)
    
    #Create ZTNA tag and rule with custom logic
    session.post(url=ztna_url, json=ztna_data_custom_logic, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Create security posture/ZTNA tag and rules response

    {
       "result":{
          "retval":1,
          "message":"Tag 'ZTNA-TAG_API' created successfully."
       },
       "data":{
          "id":81,
          "tag_uid":"8fa2287f-9c7e-43aa-a792-6cd32b16013f",
          "name":"ZTNA-TAG_API",
          "type":"zero_trust",
          "type_id":1,
          "detection_level":null,
          "description":"This is the User Notification Message",
          "update_time":"2026-06-28T14:24:12.973"
       }
    },
    {
       "result":{
          "retval":1,
          "message":"Tag 'ZTNA-TAG-CUSTOM-LOGIC_API' created successfully."
       },
       "data":{
          "id":84,
          "tag_uid":"53cf2c7d-32d7-4e6a-96d6-8ddc228dc742",
          "name":"ZTNA-TAG-CUSTOM-LOGIC_API",
          "type":"zero_trust",
          "type_id":1,
          "detection_level":null,
          "description":"This is the User Notification Message",
          "update_time":"2026-06-28T14:24:13.025"
       }
    }

    Updating security posture/ZTNA tags and rules

    If you want to update a tag and rule, you should just send the entire data to the /api/v1/tags/zero_trust/update_one endpoint as a POST, as an easy way. The problem is that a GET on /api/v1/tags/zero_trust/{ID}/get gives you different information from what the POST requires, so you’d first have to format the GET response, perform a deep merge with your new information and then send a POST. Maybe there is a better solution, but I can’t say I found one.

    Update security posture/ZTNA tag and rules

    '''
    ems_update_ztna_tag_rule.py
    Update a ZTNA tag and associated rule using the FortiClient EMS API
    
    This updates the first rule to target VPN_USERS (from ZTNA_USERS) and the second rule to search for file2.txt (from file1.txt)
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    ztna_tag_name = 'ZTNA-TAG_API'
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    ztna_get_url = f'{api_url_prefix}/tags/zero_trust/index'
    ztna_update_url = f'{api_url_prefix}/tags/zero_trust/update_one'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #fct_based means the rule is evaluated on FortiClient directly, not on EMS
    #The id value in the rules key must be incremented for each rule of a tag
    #The type should be the position of the option in the GUI, meaning that the first element of the dropdown is 1, the second 2, etc.
    #Not all types where checked if this rule of numbering holds true and this can change in the future
    #The os number is in the order in the GUI from left to right, starting at 1 for Windows
    #The content is completely dependent on the type of rule used, but most often corresponds to whatever you would enter in a field or what you can select
    updated_ztna_data = {
                    "name":"ZTNA-TAG_API",
                    "description":"This is the User Notification Message",
                    "status": True,
                    "comments":"Created using the API",
                    "rules":[
                        {
                            "negative": False,
                            "content":"GROUPS/VPN_USERS",
                            "domainName":"ad.labdomain.com",
                            "type":1,
                            "os":1,
                            "id":1,
                            "fct_based":True
                        },
                        {
                            "negative": False,
                            "content":"C:\\temp\\file2.txt",
                            "context":"",
                            "type":4,
                            "os":1,
                            "id":2,
                        }
                    ],
                    "use_custom_logic": False
                }
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get ZTNA tag information
    response = session.get(url=ztna_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for ztna_tag in response_decoded['data']['tags']:
        if ztna_tag['name'] == ztna_tag_name:
            updated_ztna_data['id'] = ztna_tag['id']
            session.post(url=ztna_update_url, json=updated_ztna_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Update security posture/ZTNA tag and rules response

    {
       "result":{
          "retval":1,
          "message":"Tag 'ZTNA-TAG_API' updated successfully."
       },
       "data":{
          "id":81,
          "name":"ZTNA-TAG_API"
       }
    }

    Compare the data of the updated_ztna_data variable with the data from a GET request on that tag, and you see the problem.

    GET security posture/ZTNA tag and rules response

    {
       "result":{
          "retval":1,
          "message":null
       },
       "data":{
          "tag_name":"ZTNA-TAG_API",
          "tag_type":1,
          "client_count":0,
          "description":"This is the User Notification Message",
          "detection_level":"",
          "tag_id":81,
          "status":true,
          "comments":"Created using the API",
          "use_custom_logic":false,
          "logic_windows":"{\"op\": \"and\", \"rules\": [{\"id\": 1}, {\"id\": 2}]}",
          "logic_mac":null,
          "logic_linux":null,
          "logic_ios":null,
          "logic_android":null,
          "error_msg":null,
          "tag_detection_type":null,
          "rules":[
             {
                "id":1,
                "os":1,
                "type":1,
                "negative":false,
                "content":"VPN_USERS",
                "context":"a79fcd99-07e2-4c75-a615-58043fd244da",
                "fct_based":true,
                "domain_name":"ad.labdomain.com"
             },
             {
                "id":2,
                "os":1,
                "type":4,
                "negative":false,
                "content":"C:\\temp\\file2.txt",
                "context":""
             }
          ]
       }
    }

    tag_id instead of id, the logic parts are completely different, and you have additional key-value pairs. It’s not impossible to do a nice update, but I didn’t create such a function for this post.

    Create an on-fabric detection rule

    This API endpoint is completely missing from the documentation, by the way.

    A funny thing about the Local IP/Subnet type. In the GUI, you are restricted in what you can enter, and you can only enter private IP ranges. If you use the API, you can set any and all subnets and IPs. You cannot edit the specific rule after this in the GUI, however.

    Create on-fabric detection rule

    '''
    ems_create_on_net_rule.py
    Create on-net rule using the FortiClient EMS API
    '''
    
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    on_net_url = f'{api_url_prefix}/on_net_rules/create'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    on_net_data = {
        "name":"ON-NET_API",
        "enabled": True,
        "comments":"On-net rule created via the API",
        "local_ip":"192.0.2.0/24",
        "dns_server_ip":"198.51.100.1",
        "public_ip":"203.0.113.1"
    }
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Create on-net rule
    session.post(url=on_net_url, json=on_net_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Create on-fabric detection rule response

    {
       "result":{
          "retval":1,
          "message":"On-fabric Detection Rule created successfully."
       },
       "data":"ON-NET_API"
    }

    Updating an on-fabric detection rule

    Much like with the ZTNA tag, the GET response is different from what you need for an update, so while /api/v1/on_net_rules/{ID}/update exists as an endpoint with a PATCH method, it is easier to send the data for the entire rule again.

    Update on-fabric detection rule

    '''
    ems_update_on_net_rule.py
    Update on-net rule using the FortiClient EMS API
    
    This updates the on-fabric rule with a new DNS server and public IP
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    on_net_rule_name = "ON-NET_API"
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    on_net_get_url = f'{api_url_prefix}/on_net_rules/index'
    on_net_update_url = f'{api_url_prefix}/on_net_rules/5/update'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    updated_on_net_data = {
        "name":f"{on_net_rule_name}",
        "enabled": True,
        "comments":"On-net rule create via the API",
        "local_ip":"192.0.2.0/24",
        "dns_server_ip":"198.51.100.100",
        "public_ip":"203.0.113.100"
    }
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get on-net rules information
    response = session.get(url=on_net_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get ID of on-net rule, set URL accordingly and update rule
    for rule in response_decoded['data']['rule_sets']:
        if rule['name'] == on_net_rule_name:
            on_net_update_url = f'{api_url_prefix}/on_net_rules/{rule['id']}/update'
            session.patch(url=on_net_update_url, json=updated_on_net_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Update on-fabric detection rule

    '''
    ems_update_on_net_rule.py
    Update on-net rule using the FortiClient EMS API
    
    This updates the on-fabric rule with a new DNS server and public IP
    '''
    
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    on_net_url = f'{api_url_prefix}/on_net_rules/5/update'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    updated_on_net_data = {
        "name":"ON-NET_API",
        "enabled": True,
        "comments":"On-net rule create via the API",
        "local_ip":"192.0.2.0/24",
        "dns_server_ip":"198.51.100.100",
        "public_ip":"203.0.113.100"
    }
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Update on-net rule
    session.patch(url=on_net_url, json=updated_on_net_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Compare the data of the updated_on_net_data variable with the data from a GET request on the rule, and you, again, see the problem.

    GET on-fabric detection rule response

    {
       "result":{
          "retval":1,
          "message":null
       },
       "data":[
          {
             "id":19,
             "rule_number":0,
             "type":8,
             "content":"198.51.100.1",
             "rule_set_id":6,
             "vdom_id":1
          },
          {
             "id":20,
             "rule_number":1,
             "type":11,
             "content":"1.1.1.0/24",
             "rule_set_id":6,
             "vdom_id":1
          },
          {
             "id":21,
             "rule_number":2,
             "type":14,
             "content":"203.0.113.1",
             "rule_set_id":6,
             "vdom_id":1
          }
       ]
    }

    We have a completely different structure here. Maybe there is a better solution, but I don’t know it.

    Creating a policy

    This is probably the most complicated piece that I cover, because a lot goes into a policy. 

    You need:

    • The assignment of imported OUs and groups, which is only a bit less difficult than the domain import
    • Get the required on-fabric detection rule ID
    • Get the IDs of each profile you want to assign

    This makes this script the one I spent the most time on, but it works.

    Create policy

    '''
    ems_create_policy.py
    Create a policy with profiles for AD OUs and groups and an on-net rule using the FortiClient EMS API
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    idp_name = "ad.labdomain.com"
    
    #Adding groups and OUs to a policy is a bit complicated, so here is an example.
    #Consider the following AD structure:
    
    #ad.labdomain.com
    #└── CLIENTS
    #└── GROUPS
    #   ├── VPN_USERS
    #   ├── ZTNA_USERS
    #└── SERVERS
    #   └── PROD
    
    #If you want to assign the entire CLIENTS OU to a policy add the name to the assigned_ous list
    #The script will get the ID of the OU and add it to the list of IDs that should get added to the policy
    
    #If you want to add the VPN_USERS group to a policy and using the structure from above you first have to get the GUID of the GROUPS OU
    #With the GUID you can then look up all the groups in that OU and get the ID for the group and add that to the list of IDs that should get added to the policy
    #In order to facilitate this add the parent OU to the parent_ous list and the groups you want to assign, that are in the parent OU, to the assigned_group_names list
    
    assigned_ous = ['CLIENTS']
    parent_ous = ['GROUPS', 'SERVERS']
    assigned_group_names = ['PROD', 'VPN_USERS']
    
    on_net_rule_name = "ON-NET_API"
    on_net_rule_id = 0
    ou_group_ids = []
    
    #You just need to supply the name for the desired profile
    on_net_profiles = {
            "vpn": {'name':'Default'},
            "ztna": {'name':'Default'},
            "webfilter": {'name':'Default'},
            "videofilter": {'name':'Default'},
            "vulnerability_scan": {'name':'Default'},
            "malware": {'name':'Default'},
            "sandbox": {'name':'Default'},
            "firewall": {'name':'Default'},
            "ftdata_scan": {'name':'Default'},
            "system": {'name':'SYS_EMS-API'}
    }
    off_net_profiles = {
            "vpn": {'name':'VPN_EMS-API'},
            "ztna": {'name':'Default'},
            "webfilter": {'name':'Default'},
            "videofilter": {'name':'Default'},
            "vulnerability_scan": {'name':'Default'},
            "malware": {'name':'Default'},
            "sandbox": {'name':'Default'},
            "firewall": {'name':'Default'},
            "ftdata_scan": {'name':'Default'},
            "system": {'name':'SYS_EMS-API'}
    }
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    policy_url = f'{api_url_prefix}/endpoint_policies/create'
    idps_url = f'{api_url_prefix}/idps/index'
    on_net_url = f'{api_url_prefix}/on_net_rules/index'
    
    #This list is used later to loop through so we don't need to reuse code
    profile_type_names = ['vpn', 'ztna','webfilter','videofilter','vulnerability_scan','malware','sandbox','firewall','ftdata_scan','system']
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get authentication servers data
    response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get imported OUs from IDPS and add the IDs to the ou_group_ids list
    for idp in response_decoded['data']:
        if idp['domain_info']['name'] == idp_name:
            idp_id = idp['domain_info']['guid']
            idp_groups_url = f'{api_url_prefix}/idps/adfs/{idp_id}/imported_ous'
            response = session.get(url=idp_groups_url, headers=api_headers, verify=False, timeout=30)
            response_decoded = json.loads(response.content.decode('utf-8'))
    
            #The two ifs are used to search for the OU name in the assigned OUs, if we want to assign an entire OU to a policy and search through the parent OUs if we need to assign groups in OUs
            for ou in response_decoded['data']['group_containers']:
                if ou['name'] in assigned_ous or ou['name'] in assigned_group_names:
                    ou_group_ids.append(ou['id'])
                if ou['name'] in parent_ous:
                    ou_groups_url = f'{api_url_prefix}/idps/adfs/{ou['guid']}/imported_ous'
                    response = session.get(url=ou_groups_url, headers=api_headers, verify=False, timeout=30)
                    response_decoded = json.loads(response.content.decode('utf-8'))
                    for group in response_decoded['data']['group_containers']:
                        if group['name'] in assigned_group_names:
                            ou_group_ids.append(group['id'])
    
    #We create a list that will hold dictionaries with each ID that should be assigned to the policy
    endpoint_group_ids = []
    
    for id_entry in ou_group_ids:
        id_dict = {"id":id_entry}
        endpoint_group_ids.append(id_dict)
    
    #Get on-net rules and set ID based on given name
    response = session.get(url=on_net_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for rule in response_decoded['data']['rule_sets']:
        if rule['name'] == on_net_rule_name:
            on_net_rule_id = rule['id']
    
    #Loop through all profile types and add the ID for each named profile to the dictionary
    for profile_type in profile_type_names:
        profile_url = f'{api_url_prefix}/profiles/{profile_type}/index'
        response = session.get(url=profile_url, headers=api_headers, verify=False, timeout=30)
        response_decoded = json.loads(response.content.decode('utf-8'))
    
        for profile in response_decoded['data']['local']:
            if profile['name'] == on_net_profiles[f'{profile_type}']['name']:
                on_net_profiles[f'{profile_type}']['id'] = profile['id']
            if profile['name'] == off_net_profiles[f'{profile_type}']['name']:
                off_net_profiles[f'{profile_type}']['id'] = profile['id']
    
    policy_data = {
        "name":"POLICY_API",
        "endpoint_groups":endpoint_group_ids,
        "enable_on_off_net":True,
        "profile_components":{
            "vpn":{
                "id":on_net_profiles['vpn']['id'],
            },
            "ztna":{
                "id":on_net_profiles['ztna']['id'],
            },
            "webfilter":{
                "id":on_net_profiles['webfilter']['id'],
            },
            "videofilter":{
                "id":on_net_profiles['videofilter']['id'],
            },
            "vulnerability_scan":{
                "id":on_net_profiles['vulnerability_scan']['id'],
            },
            "malware":{
                "id":on_net_profiles['malware']['id'],
            },
            "sandbox":{
                "id":on_net_profiles['sandbox']['id'],
            },
            "firewall":{
                "id":on_net_profiles['firewall']['id'],
            },
            "ftdata_scan":{
                "id":on_net_profiles['ftdata_scan']['id'],
            },
            "system":{
                "id":on_net_profiles['system']['id'],
            }
        },
        "off_net_profile_components":{
            "vpn":{
                "id":off_net_profiles['vpn']['id'],
            },
            "ztna":{
                "id":off_net_profiles['ztna']['id'],
            },
            "webfilter":{
                "id":off_net_profiles['webfilter']['id'],
            },
            "videofilter":{
                "id":off_net_profiles['videofilter']['id'],
            },
            "vulnerability_scan":{
                "id":off_net_profiles['vulnerability_scan']['id'],
            },
            "malware":{
                "id":off_net_profiles['malware']['id'],
            },
            "sandbox":{
                "id":off_net_profiles['sandbox']['id'],
            },
            "firewall":{
                "id":off_net_profiles['firewall']['id'],
            },
            "ftdata_scan":{
                "id":off_net_profiles['ftdata_scan']['id'],
            },
            "system":{
                "id":off_net_profiles['system']['id'],
            }
        },
        "telemetry_server_list":None,
        "on_net_rules":[
            {
                "id":on_net_rule_id,
            }
        ],
        "comments":"Policy created using the API",
        "enabled":True,
    }
    
    #Create policy
    session.post(url=policy_url, json=policy_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Create policy

    {
       "result":{
          "retval":1,
          "message":"Endpoint policy created successfully."
       },
       "data":{
          "warning":null
       }
    }

    Updating a policy

    Third time’s the charm, and updating a policy has the same problem as ZTNA tags and on-fabric detection rules. The GET response is different from what you need, so just send the entire data again for an update instead of updating just the desired components.

    Update policy

    '''
    ems_update_policy.py
    Update a policy with profiles for AD OUs and groups and an on-net rule using the FortiClient EMS API
    This script updates the policy by setting different groups (from VPN_USERS to ZTNA_USERS) and a different VPN profile in the off-net profile components (from VPN_EMS-API to Default)
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    idp_name = "ad.labdomain.com"
    policy_name = "POLICY_API"
    
    #Adding groups and OUs to a policy is a bit complicated, so here is an example.
    #Consider the following AD structure:
    
    #ad.labdomain.com
    #└── CLIENTS
    #└── GROUPS
    #   ├── VPN_USERS
    #   ├── ZTNA_USERS
    #└── SERVERS
    #   └── PROD
    
    #If you want to assign the entire CLIENTS OU to a policy add the name to the assigned_ous list
    #The script will get the ID of the OU and add it to the list of IDs that should get added to the policy
    
    #If you want to add the VPN_USERS group to a policy and using the structure from above you first have to get the GUID of the GROUPS OU
    #With the GUID you can then look up all the groups in that OU and get the ID for the group and add that to the list of IDs that should get added to the policy
    #In order to facilitate this add the parent OU to the parent_ous list and the groups you want to assign, that are in the parent OU, to the assigned_group_names list
    
    assigned_ous = ["CLIENTS"]
    parent_ous = ['GROUPS', 'SERVERS']
    assigned_group_names = ['PROD', 'ZTNA_USERS']
    
    on_net_rule_name = "ON-NET_API"
    ou_group_ids = []
    on_net_rule_id = 0
    
    #You just need to supply the name for the desired profile
    on_net_profiles = {
            "vpn": {'name':'Default'},
            "ztna": {'name':'Default'},
            "webfilter": {'name':'Default'},
            "videofilter": {'name':'Default'},
            "vulnerability_scan": {'name':'Default'},
            "malware": {'name':'Default'},
            "sandbox": {'name':'Default'},
            "firewall": {'name':'Default'},
            "ftdata_scan": {'name':'Default'},
            "system": {'name':'SYS_EMS-API'}
    }
    off_net_profiles = {
            "vpn": {'name':'Default'},
            "ztna": {'name':'Default'},
            "webfilter": {'name':'Default'},
            "videofilter": {'name':'Default'},
            "vulnerability_scan": {'name':'Default'},
            "malware": {'name':'Default'},
            "sandbox": {'name':'Default'},
            "firewall": {'name':'Default'},
            "ftdata_scan": {'name':'Default'},
            "system": {'name':'SYS_EMS-API'}
    }
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    policy_get_url = f'{api_url_prefix}/endpoint_policies/index'
    idps_url = f'{api_url_prefix}/idps/index'
    on_net_url = f'{api_url_prefix}/on_net_rules/index'
    
    #This list is used later to loop through so we don't need to reuse code
    profile_type_names = ['vpn', 'ztna','webfilter','videofilter','vulnerability_scan','malware','sandbox','firewall','ftdata_scan','system']
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get authentication servers data
    response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get imported OUs from IDPS and add the IDs to the ou_group_ids list
    for idp in response_decoded['data']:
        if idp['domain_info']['name'] == idp_name:
            idp_id = idp['domain_info']['guid']
            idp_groups_url = f'{api_url_prefix}/idps/adfs/{idp_id}/imported_ous'
            response = session.get(url=idp_groups_url, headers=api_headers, verify=False, timeout=30)
            response_decoded = json.loads(response.content.decode('utf-8'))
    
            #The two ifs are used to search for the OU name in the assigned OUs, if we want to assign an entire OU to a policy and search through the parent OUs if we need to assign groups in OUs
            for ou in response_decoded['data']['group_containers']:
                if ou['name'] in assigned_ous or ou['name'] in assigned_group_names:
                    ou_group_ids.append(ou['id'])
                if ou['name'] in parent_ous:
                    ou_groups_url = f'{api_url_prefix}/idps/adfs/{ou['guid']}/imported_ous'
                    response = session.get(url=ou_groups_url, headers=api_headers, verify=False, timeout=30)
                    response_decoded = json.loads(response.content.decode('utf-8'))
                    for group in response_decoded['data']['group_containers']:
                        if group['name'] in assigned_group_names:
                            ou_group_ids.append(group['id'])
    
    #We create a list that will hold dictionaries with each ID that should be assigned to the policy
    endpoint_group_ids = []
    
    for id_entry in ou_group_ids:
        id_dict = {"id":id_entry}
        endpoint_group_ids.append(id_dict)
    
    #Get on-net rules and set ID based on given name
    response = session.get(url=on_net_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for rule in response_decoded['data']['rule_sets']:
        if rule['name'] == on_net_rule_name:
            on_net_rule_id = rule['id']
    
    #Loop through all profile types and add the ID for each named profile to the dictionary
    for profile_type in profile_type_names:
        profile_url = f'{api_url_prefix}/profiles/{profile_type}/index'
        response = session.get(url=profile_url, headers=api_headers, verify=False, timeout=30)
        response_decoded = json.loads(response.content.decode('utf-8'))
    
        for profile in response_decoded['data']['local']:
            if profile['name'] == on_net_profiles[f'{profile_type}']['name']:
                on_net_profiles[f'{profile_type}']['id'] = profile['id']
            if profile['name'] == off_net_profiles[f'{profile_type}']['name']:
                off_net_profiles[f'{profile_type}']['id'] = profile['id']
    
    updated_policy_data = {
        "name":"POLICY_API",
        "endpoint_groups":endpoint_group_ids,
        "enable_on_off_net":True,
        "profile_components":{
            "vpn":{
                "id":on_net_profiles['vpn']['id'],
            },
            "ztna":{
                "id":on_net_profiles['ztna']['id'],
            },
            "webfilter":{
                "id":on_net_profiles['webfilter']['id'],
            },
            "videofilter":{
                "id":on_net_profiles['videofilter']['id'],
            },
            "vulnerability_scan":{
                "id":on_net_profiles['vulnerability_scan']['id'],
            },
            "malware":{
                "id":on_net_profiles['malware']['id'],
            },
            "sandbox":{
                "id":on_net_profiles['sandbox']['id'],
            },
            "firewall":{
                "id":on_net_profiles['firewall']['id'],
            },
            "ftdata_scan":{
                "id":on_net_profiles['ftdata_scan']['id'],
            },
            "system":{
                "id":on_net_profiles['system']['id'],
            }
        },
        "off_net_profile_components":{
            "vpn":{
                "id":off_net_profiles['vpn']['id'],
            },
            "ztna":{
                "id":off_net_profiles['ztna']['id'],
            },
            "webfilter":{
                "id":off_net_profiles['webfilter']['id'],
            },
            "videofilter":{
                "id":off_net_profiles['videofilter']['id'],
            },
            "vulnerability_scan":{
                "id":off_net_profiles['vulnerability_scan']['id'],
            },
            "malware":{
                "id":off_net_profiles['malware']['id'],
            },
            "sandbox":{
                "id":off_net_profiles['sandbox']['id'],
            },
            "firewall":{
                "id":off_net_profiles['firewall']['id'],
            },
            "ftdata_scan":{
                "id":off_net_profiles['ftdata_scan']['id'],
            },
            "system":{
                "id":off_net_profiles['system']['id'],
            }
        },
        "telemetry_server_list":None,
        "on_net_rules":[
            {
                "id":on_net_rule_id,
            }
        ],
        "comments":"Test",
        "enabled":True,
    }
    
    #Get policy ID, set patch URL and update policy
    response = session.get(url=policy_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for policy in response_decoded['data']:
        if policy['name'] == policy_name:
            policy_update_url = f'{api_url_prefix}/endpoint_policies/{policy['id']}/update'
    
            #Update policy
            session.patch(url=policy_update_url, json=updated_policy_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Update policy response

    {
       "result":{
          "retval":1,
          "message":"Endpoint policy updated successfully."
       },
       "data":{
          "warning":"None"
       }
    }

    Look at the GET from a policy and compare it with the updated_policy_data variable.

    GET policy response

    {
       "result":{
          "retval":1,
          "message":"None"
       },
       "data":[
          {
             "id":49,
             "name":"POLICY_API",
             "enable_on_off_net":true,
             "comments":"Policy created using the API",
             "enabled":true,
             "is_default":false,
             "is_sase":false,
             "priority":1,
             "groups":{
                "195":{
                   "name":"CLIENTS",
                   "path":"ad.labdomain.com/CLIENTS"
                },
                "200":{
                   "name":"PROD",
                   "path":"ad.labdomain.com/SERVERS/PROD"
                }
             },
             "rule_sets":{
                "6":{
                   "name":"ON-NET_API",
                   "enabled":true
                }
             },
             "users":{
                
             },
             "fct_users_count":{
                "synced":1,
                "unseen":0,
                "out_of_sync":0
             },
             "profile_components":{
                "malware":{
                   "id":1,
                   "name":"Default"
                },
                "sandbox":{
                   "id":1,
                   "name":"Default"
                },
                "webfilter":{
                   "id":1,
                   "name":"Default",
                   "fp_name":"None"
                },
                "firewall":{
                   "id":1,
                   "name":"Default"
                },
                "vpn":{
                   "id":1,
                   "name":"Default"
                },
                "vulnerability_scan":{
                   "id":1,
                   "name":"Default"
                },
                "system":{
                   "id":70,
                   "name":"SYS_EMS-API"
                },
                "ztna":{
                   "id":1,
                   "name":"Default"
                },
                "videofilter":{
                   "id":1,
                   "name":"Default"
                },
                "ftdata_scan":{
                   "id":1,
                   "name":"Default"
                }
             },
             "off_net_profile_components":{
                "malware":{
                   "id":1,
                   "name":"Default"
                },
                "sandbox":{
                   "id":1,
                   "name":"Default"
                },
                "webfilter":{
                   "id":1,
                   "name":"Default",
                   "fp_name":"None"
                },
                "firewall":{
                   "id":1,
                   "name":"Default"
                },
                "vpn":{
                   "id":70,
                   "name":"VPN_EMS-API"
                },
                "vulnerability_scan":{
                   "id":1,
                   "name":"Default"
                },
                "system":{
                   "id":70,
                   "name":"SYS_EMS-API"
                },
                "ztna":{
                   "id":1,
                   "name":"Default"
                },
                "videofilter":{
                   "id":1,
                   "name":"Default"
                },
                "ftdata_scan":{
                   "id":1,
                   "name":"Default"
                }
             }
          },
          {
             "id":1,
             "name":"Default",
             "enable_on_off_net":false,
             "comments":"",
             "enabled":true,
             "is_default":true,
             "is_sase":false,
             "priority":2,
             "groups":{
                
             },
             "rule_sets":{
                
             },
             "users":{
                
             },
             "fct_users_count":{
                "synced":0,
                "unseen":0,
                "out_of_sync":0
             },
             "profile_components":{
                "malware":{
                   "id":1,
                   "name":"Default"
                },
                "sandbox":{
                   "id":1,
                   "name":"Default"
                },
                "webfilter":{
                   "id":1,
                   "name":"Default",
                   "fp_name":"None"
                },
                "firewall":{
                   "id":1,
                   "name":"Default"
                },
                "vpn":{
                   "id":1,
                   "name":"Default"
                },
                "vulnerability_scan":{
                   "id":1,
                   "name":"Default"
                },
                "system":{
                   "id":34,
                   "name":"LAB-SYS"
                },
                "ztna":{
                   "id":1,
                   "name":"Default"
                },
                "videofilter":{
                   "id":1,
                   "name":"Default"
                },
                "ftdata_scan":{
                   "id":1,
                   "name":"Default"
                }
             },
             "off_net_profile_components":{
                
             }
          }
       ]
    }

    groups instead of endpoint_groups in a different format and rule_sets instead of on_net_rules, again, in a different format.

    Creating an installer

    All installer API endpoints are missing from the documentation.

    Creating an installer isn’t that difficult, and most options are self-explanatory, but you have to keep the three variables for the version and the features in mind when working with it.

    Note: Feature 15, EDR, is only available in Cloud EMS. If you have an on-prem EMS, like me, you have to remove this feature. I have commented it out in my script. Also, installer names are kept track of internally, so you cannot create installers with the same name, even if you delete one. Maybe there is some cleanup on EMS upgrades or periodically, however.

    Create installer

    '''
    ems_create_installer.py
    Create FortiClient installer using the FortiClient EMS API
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    version_major_minor = "7.4"
    installer_name = "7.4.7"
    fct_comparable = 7004007
    system_profile_name = "SYS_EMS-API"
    system_profile_id = 0
    vpn_profile_name = "VPN_EMS-API"
    vpn_profile_id = 0
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    installer_url = f'{api_url_prefix}/assignable_installers/create'
    system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
    vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get system profiles
    response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for profile in response_decoded['data']['local']:
        if profile['name'] == system_profile_name:
            system_profile_id = {profile["id"]}
            #The id is a set, so we convert it to a list and get the only element from it
            system_profile_id = list(system_profile_id)[0]
    
    #Get VPN profiles
    response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for profile in response_decoded['data']['local']:
        if profile['name'] == vpn_profile_name:
            vpn_profile_id = {profile["id"]}
            #The id is a set, so we convert it to a list and get the only element from it
            vpn_profile_id = list(vpn_profile_id)[0]
    
    installer_data = {
        "name":"API-INSTALLER",
        "notes":"FortiClient installer created via API",
        "version_major_minor":version_major_minor,
        "auto_update":None,
        "installer_name":installer_name,
        "fct_comparable":fct_comparable,
        "windows_installer":True,
        "mac_installer":True,
        "linux_installer":True,
        "windows_arm_installer":False,
        "linux_arm_installer":False ,
        "features":[
            5, #Zero Trust Telemetry
            3, #Secure Access Architecture Components
            7, #Vulnerability Scan
            6, #Advanced Persistent Threat (APT) Components
            1, #AntiVirus, Anti-Exploit, Removable Media Access
            10, #Anti-Ransomware
            9, #Cloud Based Malware Outbreak Detection
            2, #Web and Video Filtering
            4, #Application Firewall
            8, #Single Sign-On Mobility Agent
            11, #Zero Trust Network Access
            13, #Privileged Access Agent
            16, #Data Protection
    		12#, #FIPS Certification
            #15, #EDR, only in cloud EMS, remove if on-prem EMS
        ],
        "auto_register":True,
        "desktop_shortcut":True,
        "start_menu_shortcut":False,
        "msi_files":True,
        "override_invitation_code":False,
        "group_assignment_rules_id":None,
        "vpn_profile_component_id":vpn_profile_id,
        "system_profile_component_id":system_profile_id,
        "invalid_cert_action":None,
        "telemetry_server_list_id":None
    }
    
    #Create installer
    response = session.post(url=installer_url, json=installer_data, headers=change_headers, verify=False, timeout=30)
    response_decoded = response.content.decode('utf-8')
    print(response_decoded)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Create installer response

    {
       "result":{
          "retval":1,
          "message":null
       }
    }

    Updating an installer

    Like always at this point, the GET is different from what you need, so send the entire data as your payload again.

    Update installer

    '''
    ems_update_installer.py
    Update FortiClient installer using the FortiClient EMS API
    
    This updates the existing installer by:
    * Setting Mac and Linux installers to False
    * Removing the Data Protection and FIPS Certification features
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    installer_real_name = "API-INSTALLER"
    version_major_minor = "7.4"
    installer_name = "7.4.7"
    fct_comparable = 7004007
    system_profile_name = "SYS_EMS-API"
    system_profile_id = 0
    vpn_profile_name = "VPN_EMS-API"
    vpn_profile_id = 0
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    installer_index_url = f'{api_url_prefix}/assignable_installers/index'
    system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
    vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get system profiles
    response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for profile in response_decoded['data']['local']:
        if profile['name'] == system_profile_name:
            system_profile_id = {profile["id"]}
            #The id is a set, so we convert it to a list and get the only element from it
            system_profile_id = list(system_profile_id)[0]
    
    #Get VPN profiles
    response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for profile in response_decoded['data']['local']:
        if profile['name'] == vpn_profile_name:
            vpn_profile_id = {profile["id"]}
            #The id is a set, so we convert it to a list and get the only element from it
            vpn_profile_id = list(vpn_profile_id)[0]
    
    updated_installer_data = {
        "name":"API-INSTALLER",
        "notes":"FortiClient installer updated via API",
        "version_major_minor":version_major_minor,
        "auto_update":None,
        "installer_name":installer_name,
        "fct_comparable":fct_comparable,
        "windows_installer":True,
        "mac_installer":False,
        "linux_installer":False,
        "windows_arm_installer":False,
        "linux_arm_installer":False ,
        "features":[
            5, #Zero Trust Telemetry
            3, #Secure Access Architecture Components
            7, #Vulnerability Scan
            6, #Advanced Persistent Threat (APT) Components
            1, #AntiVirus, Anti-Exploit, Removable Media Access
            10, #Anti-Ransomware
            9, #Cloud Based Malware Outbreak Detection
            2, #Web and Video Filtering
            4, #Application Firewall
            8, #Single Sign-On Mobility Agent
            11, #Zero Trust Network Access
            13, #Privileged Access Agent
        ],
        "auto_register":True,
        "desktop_shortcut":True,
        "start_menu_shortcut":False,
        "msi_files":True,
        "override_invitation_code":False,
        "group_assignment_rules_id":None,
        "vpn_profile_component_id":vpn_profile_id,
        "system_profile_component_id":system_profile_id,
        "invalid_cert_action":None,
        "telemetry_server_list_id":None
    }
    
    #Get installer ID
    response = session.get(url=installer_index_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for installers in response_decoded['data']['installers']:
        if installers['name'] == installer_real_name:
            installer_id = {installers["id"]}
            #The id is a set, so we convert it to a list and get the only element from it
            installer_id = list(installer_id)[0]
            #Assemble the URLs with the ID
            installer_update_url = f'{api_url_prefix}/assignable_installers/{installer_id}/update'
    
            #Update installer
            session.patch(url=installer_update_url, json=updated_installer_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Update installer response

    {
       "result":{
          "retval":1,
          "message":null
       }
    }

    If we look at the GET, we can see that there is a lot more information in an installer, like the entire VPN and system settings profile information, if you have attached one.

    GET installer response

    {
       "result":{
          "retval":1,
          "message":null
       },
       "data":{
          "id":35,
          "fds":true,
          "name":"API-INSTALLER",
          "notes":"FortiClient installer updated via API",
          "folder":"api-installer",
          "auto_register":true,
          "desktop_shortcut":true,
          "start_menu_shortcut":false,
          "msi_files":true,
          "override_invitation_code":false,
          "windows_installer":true,
          "windows_arm_installer":false,
          "linux_installer":false,
          "linux_arm_installer":false,
          "mac_installer":false,
          "invalid_cert_action":null,
          "features":[
             1,
             2,
             3,
             4,
             5,
             6,
             7,
             8,
             9,
             10,
             11,
             13
          ],
          "uninstaller":false,
          "installer_name":"7.4.7",
          "installer_win":{
             "id":1,
             "name":"7.4.7",
             "is_official":true,
             "os":"win",
             "custom":0,
             "version_major_minor":"7.4",
             "version":"7.4.7",
             "version_comparable":7004007
          },
          "installer_win_invalid":false,
          "installer_mac":{
             "id":1,
             "name":"7.4.7",
             "is_official":true,
             "os":"osx",
             "custom":0,
             "version_major_minor":"7.4",
             "version":"7.4.7",
             "version_comparable":7004007
          },
          "installer_mac_invalid":false,
          "installer_linux":{
             "id":1,
             "name":"7.4.7",
             "is_official":true,
             "os":"lin",
             "custom":0,
             "version_major_minor":"7.4",
             "version":"7.4.7",
             "version_comparable":7004007
          },
          "installer_linux_invalid":false,
          "telemetry_server_list":{
             
          },
          "group_assignment_rule":{
             
          },
          "supported":true,
          "standalone_invitation_code":null,
          "vpn_component":{
             "id":70,
             "name":"VPN_EMS-API",
             "json":{
                "vpn":{
                   "sslvpn":{
                      "connections":[
                         
                      ],
                      "options":{
                         "dtls_mtu":1100,
                         "no_dns_registration":0,
                         "show_auth_cert_only":0,
                         "disallow_invalid_server_certificate":0,
                         "use_gui_saml_auth":0,
                         "warn_invalid_server_certificate":1,
                         "block_ipv6":1,
                         "negative_split_tunnel_metric":null,
                         "preferred_dtls_tunnel":0,
                         "mtu_size":1300,
                         "dnscache_service_control":0,
                         "enabled":0,
                         "prefer_sslvpn_dns":1
                      }
                   },
                   "ipsecvpn":{
                      "connections":[
                         {
                            "name":"API-IPSEC-VPN",
                            "pinned":0,
                            "dns_priority":1,
                            "machine":0,
                            "keep_running":0,
                            "traffic_keep_strategy":0,
                            "traffic_keep_timer":5000,
                            "disclaimer_msg":"",
                            "single_user_mode":0,
                            "ui":{
                               "show_remember_password":0,
                               "show_alwaysup":0,
                               "show_autoconnect":0,
                               "show_passcode":0,
                               "save_username":1
                            },
                            "traffic_control":{
                               "enabled":0,
                               "mode":1,
                               "apps":[
                                  
                               ],
                               "fqdns":[
                                  
                               ],
                               "isdb_objects":[
                                  
                               ],
                               "vsdb_objects":[
                                  
                               ]
                            },
                            "redundant_sort_method":0,
                            "tags":{
                               "allowed":"",
                               "prohibited":""
                            },
                            "host_check_fail_warning":"",
                            "ike_settings":{
                               "server":"192.0.2.254",
                               "authentication_method":"Preshared Key",
                               "auth_data":"Enc f7446dfccd809fbf6486f27f81c515eafa55049574aae9672f18533318cdb297e0",
                               "transport_mode":0,
                               "tcp_port":443,
                               "udp_port":500,
                               "cert_subjectcheck":0,
                               "prompt_certificate":0,
                               "xauth_timeout":120,
                               "xauth":{
                                  "use_otp":0,
                                  "enabled":0,
                                  "prompt_username":0,
                                  "username":"",
                                  "password":""
                               },
                               "version":2,
                               "mode":"aggressive",
                               "dhgroup":[
                                  21
                               ],
                               "key_life":28800,
                               "localid":"",
                               "networkid":0,
                               "eap_method":1,
                               "implied_SPDO":0,
                               "implied_SPDO_timeout":60,
                               "nat_traversal":1,
                               "enable_local_lan":1,
                               "session_resume":1,
                               "enable_ike_fragmentation":1,
                               "mode_config":1,
                               "modeconfig_type":0,
                               "dpd":1,
                               "proposals":[
                                  {
                                     "encryption":"AES128",
                                     "authentication":"SHA256"
                                  },
                                  {
                                     "encryption":"AES256",
                                     "authentication":"SHA256"
                                  }
                               ],
                               "run_fcauth_system":0,
                               "failover_sslvpn_connection":"",
                               "sso_enabled":0,
                               "use_external_browser":0,
                               "ike_saml_port":443,
                               "keep_fqdn_resolution_consistency":0,
                               "no_vnic_dns_server":0,
                               "azure_auto_login":{
                                  "enabled":0,
                                  "azure_app":{
                                     "tenant_name":"",
                                     "client_id":""
                                  }
                               },
                               "fgt":1,
                               "dpd_retry_count":3,
                               "dpd_retry_interval":20,
                               "certificate":null,
                               "nat_alive_freq":10
                            },
                            "ipsec_settings":{
                               "remote_networks":[
                                  {
                                     "addr":"0.0.0.0",
                                     "mask":"0.0.0.0"
                                  },
                                  {
                                     "addr":"::/0",
                                     "mask":"::/0"
                                  }
                               ],
                               "dhgroup":31,
                               "key_life_type":"seconds",
                               "key_life_seconds":3600,
                               "key_life_Kbytes":5200,
                               "replay_detection":1,
                               "pfs":1,
                               "virtualip":{
                                  "type":"modeconfig",
                                  "ip":"0.0.0.0",
                                  "mask":"0.0.0.0",
                                  "dnsserver":"0.0.0.0",
                                  "winserver":"0.0.0.0"
                               },
                               "proposals":[
                                  {
                                     "encryption":"AES256GCM",
                                     "authentication":"NONE"
                                  },
                                  {
                                     "encryption":"AES256",
                                     "authentication":"SHA512"
                                  }
                               ],
                               "ipv4_split_exclude_networks":[
                                  
                               ],
                               "use_vip":1
                            },
                            "on_connect":[
                               {
                                  "os":"windows",
                                  "script":""
                               },
                               {
                                  "os":"MacOSX",
                                  "script":""
                               }
                            ],
                            "on_disconnect":[
                               {
                                  "os":"windows",
                                  "script":""
                               },
                               {
                                  "os":"MacOSX",
                                  "script":""
                               }
                            ],
                            "android_cert_path":"",
                            "uid":"B0A6E39F-F101-46DC-B94E-2F42A375ECF7",
                            "warn_invalid_server_certificate":1,
                            "type":"manual"
                         }
                      ],
                      "options":{
                         "disable_default_route":0,
                         "block_ipv6":1,
                         "use_win_local_computer_cert":1,
                         "check_for_cert_private_key":0,
                         "mtu_size":1280,
                         "usesmcardcert":1,
                         "beep_if_error":0,
                         "enhanced_key_usage_mandatory":0,
                         "no_dns_registration":0,
                         "show_auth_cert_only":0,
                         "disallow_invalid_server_certificate":0,
                         "prefer_ipsecvpn_dns":1,
                         "use_gui_saml_auth":0,
                         "use_win_current_user_cert":1,
                         "enable_udp_checksum":0,
                         "usewincert":1,
                         "enabled":1
                      }
                   },
                   "enabled":1,
                   "lockdown":{
                      "grace_period":120,
                      "max_attempts":3,
                      "detect_captive_portal":{
                         "enabled":0,
                         "os_active_probing":1
                      },
                      "exceptions":{
                         "domains":[
                            
                         ],
                         "ips":[
                            
                         ],
                         "apps":[
                            
                         ],
                         "icdb_domains":[
                            
                         ]
                      },
                      "enabled":0
                   },
                   "options":{
                      "after_logon_saml_auth":0,
                      "temp_password":"Start123$",
                      "enable_view_selected_vpns":0,
                      "minimize_window_on_connect":1,
                      "enable_multi_vpn":0,
                      "autoconnect_tunnel":"",
                      "show_vpn_before_logon":1,
                      "on_os_start_connect":"",
                      "secure_remote_access":0,
                      "current_connection_type":"",
                      "certs_require_keyspec":0,
                      "disable_internet_check":1,
                      "autoconnect_on_install":0,
                      "allow_personal_vpns":0,
                      "disconnect_password":"",
                      "vendor_id":"",
                      "use_windows_credentials":0,
                      "autoconnect_only_when_offnet":0,
                      "current_connection_name":"",
                      "before_logon_saml_auth":1,
                      "disable_connect_disconnect":0,
                      "on_os_start_connect_has_priority":0,
                      "suppress_vpn_notification":0,
                      "use_webview2_saml_auth":0,
                      "keep_running_max_tries":0,
                      "enforce_disabling_smartdns":0
                   },
                   "display_vpn":1
                }
             },
             "is_chromebook":false,
             "is_default":false,
             "is_sase":false,
             "update_time":"2026-06-28T14:13:37.294",
             "enabled":true,
             "display_enabled":true,
             "parser_error":null,
             "type":5
          },
          "system_component":{
             "id":70,
             "name":"SYS_EMS-API",
             "json":{
                "fssoma":{
                   "enabled":0,
                   "presharedkey":"",
                   "serveraddress":""
                },
                "version":"5.6.0",
                "pam":{
                   "enabled":0,
                   "default_port":9191
                },
                "endpoint_control":{
                   "forensics_license":1,
                   "enable_dns_cache":0,
                   "enable_dem":0,
                   "send_software_inventory":0,
                   "disable_fgt_switch":0,
                   "auto_start":0,
                   "disable_unregister":1,
                   "notify_fgt_on_logoff":0,
                   "invalid_cert_action":"warn",
                   "ui":{
                      "hide_compliance_warning":0
                   },
                   "show_bubble_notifications":0,
                   "edr_collector":1
                },
                "system":{
                   "certificates":[
                      
                   ],
                   "cryptography":{
                      "drbg_reseed_minutes":1440
                   },
                   "update":{
                      "timeout":60,
                      "auto_patch":0,
                      "fail_over_to_fdn":0,
                      "restrict_services_to_regions":"",
                      "scheduled_update":{
                         "type":"interval",
                         "enabled":1,
                         "daily_at":"00:00",
                         "update_interval_in_hours":1
                      },
                      "submit_virus_info_to_fds":1,
                      "port":80,
                      "update_action":"disable",
                      "failoverport":8000,
                      "use_legacy_fdn":0,
                      "ocsp_mode":0,
                      "submit_vuln_info_to_fds":1,
                      "server":"",
                      "use_custom_server":0
                   },
                   "proxy":{
                      "username":null,
                      "update":0,
                      "fail_over_to_fdn":0,
                      "port":80,
                      "virus_submission":0,
                      "type":"http",
                      "password":"",
                      "address":null,
                      "online_scep":0
                   },
                   "installer":{
                      "allow_admin_uninstall_when_locked":1
                   },
                   "proc_protect":1,
                   "user_identity":{
                      "enable_google":0,
                      "enable_linkedin":0,
                      "enable_manually_entering":0,
                      "notify_user":0,
                      "enable_salesforce":0
                   },
                   "log_settings":{
                      "onnet_local_logging":1,
                      "level":6,
                      "log_events":"antiexploit,antiransomware,av,cloudscan,endpoint,firewall,fssoma,ipsecvpn,pam,sandboxing,sslvpn,update,vuln,webfilter,ztna,configd,scheduler,shield,wanacc",
                      "remote_logging":{
                         "send_os_events":{
                            "enabled":1,
                            "interval":120
                         },
                         "log_upload_enabled":0,
                         "log_retention_days":90,
                         "log_upload_ssl_enabled":1,
                         "send_software_inventory":0,
                         "log_upload_server":"",
                         "log_generation_timeout_secs":900,
                         "netlog_categories":32,
                         "log_upload_freq_minutes":60,
                         "log_compressed":0
                      }
                   },
                   "fortiproxy":{
                      "http_timeout":60,
                      "selftest":{
                         "notify":1,
                         "enabled":1,
                         "last_port":65535
                      },
                      "client_comforting":{
                         "pop3_client":1,
                         "pop3_server":1,
                         "smtp":1
                      },
                      "enable_https_proxy":1,
                      "enabled":1
                   },
                   "ui":{
                      "allow_shutdown_when_registered":0,
                      "disable_backup":0,
                      "unreg_pwd":"goBsfMBfsTRbI3732fJADZ2gJEqSFB7zeBKnNb2U3WRDVgKla5vP6XG4xPpK85mDIpq2xSvSfyagMMdQ7Hl73WiOMntt5LdOOgUvUi0MT8Oq74vif2XsiFd3tf57SFV7$TQJ1qTiQelcT/qoHkN4laeQI6jvKLeoe+1WevZOq9oBktCLyBB4goInKHqF9jiPGDNhrT7rToVWNEXKeqF8myQ==",
                      "lock":"Enc 283431078f7ac0acbb5fd257d3279947f5cd01bbe523458b9d50b1a1d5a8bf373b784abec45318e4646034cf13d32e00df29a194c3a247b9db53f6e768362e37239b7005d02266424b0efd63aac9c86669a86c0a0368caa7",
                      "password":"Enc 68be3193bbd992ab167dd677ba394598d6c827dcedc1d91d842296f9fcfb9cc97b6d62fc1e38124e0b1ea9dc2233665e1a76f192808a1f09",
                      "culture_code":"os-default",
                      "hide_user_info":0,
                      "default_tab":"VPN",
                      "hide_system_tray_icon":0,
                      "show_host_tag":0
                   }
                },
                "wan_optimization":{
                   "support_cifs":1,
                   "support_http":1,
                   "support_ftp":1,
                   "enabled":0,
                   "support_mapi":1,
                   "max_disk_cache_size_mb":512
                },
                "extra":{
                   "trigger_vuln_scan":true
                }
             },
             "is_chromebook":false,
             "is_default":false,
             "is_sase":false,
             "update_time":"2026-06-28T14:13:37.243",
             "enabled":true,
             "display_enabled":true,
             "parser_error":null,
             "type":7
          },
          "assembly_error_type":0,
          "assembly_state":2,
          "assembly_progress":100,
          "hotfix_name":null,
          "hotfix_details":[
             
          ],
          "auto_update":null
       }
    }

    Creating an invitation

    If you work with Cloud EMS, note that the API endpoints use /cloud/invitations instead of the on-prem /api/v1/invitation

    Invitations are relatively straightforward, and the only thing of note is that if you want to have an installer attached, you need to create it with the invitation. You cannot create an invitation and later attach an installer.

    Create invitation

    '''
    ems_create_invitation.py
    Create invitation with domain verification and installer using the FortiClient EMS API
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    version_major_minor = "7.4"
    installer_name = "7.4.7"
    fct_comparable = 7004007
    system_profile_name = "SYS_EMS-API"
    system_profile_id = 0
    vpn_profile_name = "VPN_EMS-API"
    vpn_profile_id = 0
    idp_name = "ad.labdomain.com"
    idp_id = 0
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    invitation_url = f'{api_url_prefix}/invitation/create'
    system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
    vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
    idps_url = f'{api_url_prefix}/idps/index'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get system profiles
    response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for profile in response_decoded['data']['local']:
        if profile['name'] == system_profile_name:
            system_profile_id = {profile["id"]}
            #The id is a set, so we convert it to a list and get the only element from it
            system_profile_id = list(system_profile_id)[0]
    
    #Get VPN profiles
    response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    for profile in response_decoded['data']['local']:
        if profile['name'] == vpn_profile_name:
            vpn_profile_id = {profile["id"]}
            #The id is a set, so we convert it to a list and get the only element from it
            vpn_profile_id = list(vpn_profile_id)[0]
    
    #Get authentication servers data
    response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get all the necessary information directly from the IDP and create the data for the domain import
    for idp in response_decoded['data']:
        if idp['domain_info']['name'] == idp_name:
            idp_id = idp['domain_info']['guid']
    
    #Use None for expiry_date to have no expiration
    #The mapping for the authentication type is 0=None, 1=Local, 2=Domain, 3=SAML
    invitation_data = {
        "name":"API-INVITATION-INSTALLER",
        "comments":"Invitation created via API",
        "has_email_notifications":False,
        "expiry_date":"2026-12-31",
        "authentication_type":2,
        "email_template_id":1,
        "is_bulk":True,
        "listen_address":f"{ems_server}:8013",
        "assignable_installer":{
            "name":"INVITATION-INSTALLER",
            "notes":"FortiClient invitation installer created via API",
            "version_major_minor":version_major_minor,
            "auto_update":None,
            "installer_name":installer_name,
            "fct_comparable":fct_comparable,
            "windows_installer":True,
            "mac_installer":True,
            "linux_installer":True,
            "windows_arm_installer":False,
            "linux_arm_installer":False ,
            "features":[
                5, #Zero Trust Telemetry
                3, #Secure Access Architecture Components
                7, #Vulnerability Scan
                6, #Advanced Persistent Threat (APT) Components
                1, #AntiVirus, Anti-Exploit, Removable Media Access
                10, #Anti-Ransomware
                9, #Cloud Based Malware Outbreak Detection
                2, #Web and Video Filtering
                4, #Application Firewall
                8, #Single Sign-On Mobility Agent
                11, #Zero Trust Network Access
                13, #Privileged Access Agent
                16, #Data Protection
                12#, #FIPS Certification
                #15, #EDR, only in cloud EMS, remove if on-prem EMS
            ],
            "auto_register":True,
            "desktop_shortcut":True,
            "start_menu_shortcut":False,
            "msi_files":True,
            "override_invitation_code":False,
            "group_assignment_rules_id":None,
            "vpn_profile_component_id":vpn_profile_id,
            "system_profile_component_id":system_profile_id,
            "invalid_cert_action":None,
            "telemetry_server_list_id":None
        },
        "domain_guid":idp_id,
        "user_id":None,
        "saml_config_id":None
    }
    
    #Create invitation
    session.post(url=invitation_url, json=invitation_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Create invitation response

    {
       "result":{
          "retval":1,
          "message":null
       },
       "data":{
          "id":7,
          "uid":"6f868940-eca1-4880-8ac0-21f8ba350158",
          "name":"API-INVITATION-INSTALLER",
          "invitation_code":"_VjE6MTkyLjE2OC4xLjIwODo4MDEzOmRlZmF1bHQ6NmY4Njg5NDAtZWNhMS00ODgwLThhYzAtMjFmOGJhMzUwMTU4"
       }
    }

    Updating an invitation

    This process is pointless. Apparently, you can only change the name, EMS listen address, and comment of an existing invitation, even if you do update it. You can send more information, like changing the Verification Type, but it doesn’t actually update it. There is an update script in the repository, but I don’t bother posting one here.

    Getting endpoint data and matching on the endpoint name

    Probably the most fundamental API call and it’s almost at the end.

    The script gets all endpoint data using the API and performs a match on a specific endpoint using the endpoint’s name.

    GET endpoint data and match on endpoint name

    '''
    ems_get_endpoint_data.py
    Get endpoint data and ID for named endpoint using the FortiClient EMS API
    '''
    
    import json
    import requests
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    endpoint_name = "WIN11-CLIENT"
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    endpoints_url = f'{api_url_prefix}/endpoints/index'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get endpoint data
    response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    print(response_decoded)
    
    #Get endpoint ID with name match
    for endpoint in response_decoded['data']['endpoints']:
        if endpoint['name'] == endpoint_name:
            print(f"Endpoint ID for {endpoint_name}: {endpoint['device_id']}")
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    GET endpoint data and match on endpoint name response

    {
       "result":{
          "retval":1,
          "message":"None"
       },
       "data":{
          "endpoints":[
             {
                "device_id":55,
                "host":"FCXLAB",
                "name":"FCXLAB",
                "ip_addr":"None",
                "os_version":"Microsoft Windows 11 Pro",
                "model":"",
                "vendor":"",
                "cpu":"",
                "memory":0,
                "sn":"",
                "hdd":"None",
                "public_ip_addr":"None",
                "domain_id":2,
                "installer_name":"None",
                "deployment_state":"None",
                "fdc_campaign_deployment_status":0,
                "fgt_sn":"None",
                "forticlient_id":"None",
                "uid":"None",
                "fct_version":"None",
                "diskenc":"None",
                "av_product":"None",
                "is_installed":false,
                "is_managed":false,
                "is_migrating":false,
                "is_ems_registered":"None",
                "can_quarantine":"None",
                "is_ems_online":false,
                "is_ems_onnet":"None",
                "is_excluded":false,
                "is_quarantined":0,
                "quarantine_access_code":"None",
                "invitation_name":"None",
                "invitation_code":"None",
                "invitation_version":"None",
                "comparable_fct_version":"None",
                "last_seen":"None",
                "last_seen_fct_user_id":"None",
                "endpoint_policy_name":"None",
                "endpoint_policy_id":"None",
                "ip_list_name":"None",
                "orig_groups":[
                   
                ],
                "av_enabled":"None",
                "rtp_enabled":"None",
                "ae_enabled":"None",
                "cs_enabled":"None",
                "rm_enabled":"None",
                "fw_enabled":"None",
                "wf_enabled":"None",
                "vf_enabled":"None",
                "vpn_enabled":"None",
                "vuln_enabled":"None",
                "ssoma_enabled":"None",
                "sb_enabled":"None",
                "sb_cloud_enabled":"None",
                "fd_enabled":"None",
                "rs_enabled":"None",
                "edr_installed":"None",
                "edr_app_enabled":"None",
                "edr_feature_enabled":"None",
                "onboarding_supported":"None",
                "client_version_up_to_date":true,
                "client_av_sig_version_up_to_date":true,
                "client_policy_synced":true,
                "client_policy_primary_synced":true,
                "client_policy_offnet_synced":true,
                "client_policy_iplist_synced":true,
                "client_policy_onnet_rule_synced":true,
                "client_policy_verification_rule_synced":true,
                "sys_events_count":0,
                "av_events_count":0,
                "wf_events_count":0,
                "vf_events_count":0,
                "fw_events_count":0,
                "sb_events_count":0,
                "fd_events_count":0,
                "ae_events_count":0,
                "rm_events_count":0,
                "cs_events_count":0,
                "unreg_events_count":0,
                "rs_events_count":0,
                "nwifsc_events_count":0,
                "owmsg_events_count":0,
                "reg_events_count":0,
                "ztna_sign_events_count":0,
                "ztna_revoke_events_count":0,
                "pam_events_count":0,
                "vcm_events_count":0,
                "vpn_events_count":0,
                "vuln_events_count":0,
                "pua_events_count":0,
                "vuln_events_max_severity":"None",
                "fdc_events_count":0,
                "forensics_enabled":false,
                "groups":[
                   {
                      "group_id":195,
                      "group_name":"CLIENTS",
                      "group_path":"ad.labdomain.com/CLIENTS"
                   }
                ],
                "profile_components":{
                   
                },
                "off_net_profile_components":{
                   
                },
                "health_warning_count":0,
                "health_error_count":0
             },
             {
                "device_id":1,
                "host":"WIN11-CLIENT",
                "name":"WIN11-CLIENT",
                "ip_addr":"192.168.1.231",
                "os_version":"Microsoft Windows 11 Professional Edition, 64-bit (build 26200)",
                "model":"VMware20,1",
                "vendor":"VMware, Inc.",
                "cpu":"Intel(R) Core(TM) i7-14700K",
                "memory":8190,
                "sn":"VMware-56 4d e8 7f a0 41 62 67-9d a7 24 c8 bb e7 ff 97",
                "hdd":63,
                "public_ip_addr":"",
                "domain_id":2,
                "installer_name":"None",
                "deployment_state":"None",
                "fdc_campaign_deployment_status":"None",
                "fgt_sn":"None",
                "forticlient_id":1,
                "uid":"6E23E7C3ABBF4FE797BB811A42F0A6F3",
                "fct_version":"7.4.7.2003",
                "diskenc":"",
                "av_product":"Antivirus Microsoft Defender",
                "is_installed":true,
                "is_managed":true,
                "is_migrating":false,
                "is_ems_registered":true,
                "can_quarantine":true,
                "is_ems_online":true,
                "is_ems_onnet":false,
                "is_excluded":false,
                "is_quarantined":0,
                "quarantine_access_code":"None",
                "invitation_name":"None",
                "invitation_code":"None",
                "invitation_version":"None",
                "comparable_fct_version":7004007,
                "last_seen":"2026-06-29T16:20:38",
                "last_seen_fct_user_id":35,
                "endpoint_policy_name":"POLICY_API",
                "endpoint_policy_id":53,
                "ip_list_name":"None",
                "orig_groups":[
                   
                ],
                "av_enabled":false,
                "rtp_enabled":false,
                "ae_enabled":false,
                "cs_enabled":false,
                "rm_enabled":false,
                "fw_enabled":false,
                "wf_enabled":false,
                "vf_enabled":false,
                "vpn_enabled":true,
                "vuln_enabled":true,
                "ssoma_enabled":false,
                "sb_enabled":false,
                "sb_cloud_enabled":false,
                "fd_enabled":false,
                "rs_enabled":false,
                "edr_installed":false,
                "edr_app_enabled":false,
                "edr_feature_enabled":false,
                "onboarding_supported":true,
                "client_version_up_to_date":true,
                "client_av_sig_version_up_to_date":true,
                "client_policy_synced":true,
                "client_policy_primary_synced":true,
                "client_policy_offnet_synced":true,
                "client_policy_iplist_synced":true,
                "client_policy_onnet_rule_synced":true,
                "client_policy_verification_rule_synced":true,
                "sys_events_count":110,
                "av_events_count":0,
                "wf_events_count":0,
                "vf_events_count":0,
                "fw_events_count":0,
                "sb_events_count":0,
                "fd_events_count":0,
                "ae_events_count":0,
                "rm_events_count":0,
                "cs_events_count":0,
                "unreg_events_count":0,
                "rs_events_count":0,
                "nwifsc_events_count":101,
                "owmsg_events_count":0,
                "reg_events_count":0,
                "ztna_sign_events_count":0,
                "ztna_revoke_events_count":1,
                "pam_events_count":0,
                "vcm_events_count":0,
                "vpn_events_count":0,
                "vuln_events_count":1,
                "pua_events_count":0,
                "vuln_events_max_severity":7.800000190734863,
                "fdc_events_count":0,
                "forensics_enabled":false,
                "groups":[
                   {
                      "group_id":195,
                      "group_name":"CLIENTS",
                      "group_path":"ad.labdomain.com/CLIENTS"
                   }
                ],
                "profile_components":{
                   "malware":{
                      "id":1,
                      "name":"Default"
                   },
                   "sandbox":{
                      "id":1,
                      "name":"Default"
                   },
                   "webfilter":{
                      "id":1,
                      "name":"Default",
                      "fp_name":"None"
                   },
                   "firewall":{
                      "id":1,
                      "name":"Default"
                   },
                   "vpn":{
                      "id":1,
                      "name":"Default"
                   },
                   "vulnerability_scan":{
                      "id":1,
                      "name":"Default"
                   },
                   "system":{
                      "id":70,
                      "name":"SYS_EMS-API"
                   },
                   "ztna":{
                      "id":1,
                      "name":"Default"
                   },
                   "videofilter":{
                      "id":1,
                      "name":"Default"
                   },
                   "ftdata_scan":{
                      "id":1,
                      "name":"Default"
                   }
                },
                "off_net_profile_components":{
                   "malware":{
                      "id":1,
                      "name":"Default"
                   },
                   "sandbox":{
                      "id":1,
                      "name":"Default"
                   },
                   "webfilter":{
                      "id":1,
                      "name":"Default",
                      "fp_name":"None"
                   },
                   "firewall":{
                      "id":1,
                      "name":"Default"
                   },
                   "vpn":{
                      "id":1,
                      "name":"Default"
                   },
                   "vulnerability_scan":{
                      "id":1,
                      "name":"Default"
                   },
                   "system":{
                      "id":70,
                      "name":"SYS_EMS-API"
                   },
                   "ztna":{
                      "id":1,
                      "name":"Default"
                   },
                   "videofilter":{
                      "id":1,
                      "name":"Default"
                   },
                   "ftdata_scan":{
                      "id":1,
                      "name":"Default"
                   }
                },
                "health_warning_count":1,
                "health_error_count":0,
                "fct_users":[
                   {
                      "auth_user_name":"None",
                      "machine_user_name":"labuser",
                      "auth_user_id":"None",
                      "machine_user_id":105,
                      "auth_domain":"None",
                      "machine_domain":"ad.labdomain.com",
                      "avatar":"None",
                      "fct_user_id":35,
                      "client_id":1,
                      "last_seen":"2026-06-29T16:20:38",
                      "is_authenticated":true,
                      "is_latest":true,
                      "display_name":"labuser",
                      "user_email":"labuser@ad.labdomain.com",
                      "user_phone":"None",
                      "row_no":1
                   },
                   {
                      "auth_user_name":"None",
                      "machine_user_name":"adkevin",
                      "auth_user_id":"None",
                      "machine_user_id":35,
                      "auth_domain":"None",
                      "machine_domain":"ad.labdomain.com",
                      "avatar":"None",
                      "fct_user_id":34,
                      "client_id":1,
                      "last_seen":"2026-06-28T12:17:49",
                      "is_authenticated":true,
                      "is_latest":false,
                      "display_name":"adkevin",
                      "user_email":"labuser@ad.labdomain.com",
                      "user_phone":"None",
                      "row_no":2
                   },
                   {
                      "auth_user_name":"None",
                      "machine_user_name":"adkevin",
                      "auth_user_id":"None",
                      "machine_user_id":2,
                      "auth_domain":"None",
                      "machine_domain":"ad.labdomain.com",
                      "avatar":"None",
                      "fct_user_id":1,
                      "client_id":1,
                      "last_seen":"2026-06-19T08:38:38",
                      "is_authenticated":false,
                      "is_latest":false,
                      "display_name":"adkevin",
                      "user_email":"labuser@ad.labdomain.com",
                      "user_phone":"",
                      "row_no":3
                   }
                ]
             },
             {
                "device_id":56,
                "host":"WIN-SERVER",
                "name":"WIN-SERVER",
                "ip_addr":"None",
                "os_version":"Microsoft Windows Server 2022 Datacenter Evaluation",
                "model":"",
                "vendor":"",
                "cpu":"",
                "memory":0,
                "sn":"",
                "hdd":"None",
                "public_ip_addr":"None",
                "domain_id":2,
                "installer_name":"None",
                "deployment_state":"None",
                "fdc_campaign_deployment_status":0,
                "fgt_sn":"None",
                "forticlient_id":"None",
                "uid":"None",
                "fct_version":"None",
                "diskenc":"None",
                "av_product":"None",
                "is_installed":false,
                "is_managed":false,
                "is_migrating":false,
                "is_ems_registered":"None",
                "can_quarantine":"None",
                "is_ems_online":false,
                "is_ems_onnet":"None",
                "is_excluded":false,
                "is_quarantined":0,
                "quarantine_access_code":"None",
                "invitation_name":"None",
                "invitation_code":"None",
                "invitation_version":"None",
                "comparable_fct_version":"None",
                "last_seen":"None",
                "last_seen_fct_user_id":"None",
                "endpoint_policy_name":"None",
                "endpoint_policy_id":"None",
                "ip_list_name":"None",
                "orig_groups":[
                   
                ],
                "av_enabled":"None",
                "rtp_enabled":"None",
                "ae_enabled":"None",
                "cs_enabled":"None",
                "rm_enabled":"None",
                "fw_enabled":"None",
                "wf_enabled":"None",
                "vf_enabled":"None",
                "vpn_enabled":"None",
                "vuln_enabled":"None",
                "ssoma_enabled":"None",
                "sb_enabled":"None",
                "sb_cloud_enabled":"None",
                "fd_enabled":"None",
                "rs_enabled":"None",
                "edr_installed":"None",
                "edr_app_enabled":"None",
                "edr_feature_enabled":"None",
                "onboarding_supported":"None",
                "client_version_up_to_date":true,
                "client_av_sig_version_up_to_date":true,
                "client_policy_synced":true,
                "client_policy_primary_synced":true,
                "client_policy_offnet_synced":true,
                "client_policy_iplist_synced":true,
                "client_policy_onnet_rule_synced":true,
                "client_policy_verification_rule_synced":true,
                "sys_events_count":0,
                "av_events_count":0,
                "wf_events_count":0,
                "vf_events_count":0,
                "fw_events_count":0,
                "sb_events_count":0,
                "fd_events_count":0,
                "ae_events_count":0,
                "rm_events_count":0,
                "cs_events_count":0,
                "unreg_events_count":0,
                "rs_events_count":0,
                "nwifsc_events_count":0,
                "owmsg_events_count":0,
                "reg_events_count":0,
                "ztna_sign_events_count":0,
                "ztna_revoke_events_count":0,
                "pam_events_count":0,
                "vcm_events_count":0,
                "vpn_events_count":0,
                "vuln_events_count":0,
                "pua_events_count":0,
                "vuln_events_max_severity":"None",
                "fdc_events_count":0,
                "forensics_enabled":false,
                "groups":[
                   {
                      "group_id":198,
                      "group_name":"SERVERS",
                      "group_path":"ad.labdomain.com/SERVERS"
                   }
                ],
                "profile_components":{
                   
                },
                "off_net_profile_components":{
                   
                },
                "health_warning_count":0,
                "health_error_count":0
             }
          ],
          "total":3
       }
    }

    Getting endpoint(s) of a named user

    If you want to get all endpoints where a user is recorded as the last seen user, you can use this script.

    GET endpoint of named user

    '''
    ems_get_named_user_endpoint.py
    Get endpoint for named user using the FortiClient EMS API
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    user_name = "labuser"
    user_id = [] #This is a list, because a user can be associated with multiple endpoints
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    endpoints_url = f'{api_url_prefix}/endpoints/index'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Get endpoint data
    response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get user FortiClient ID with name match, then get endpoint ID with user ID match
    for endpoint in response_decoded['data']['endpoints']:
        #This try exists, because fct_users is not available on an endpoint with no last seen user
        try:
            for fct_user in endpoint['fct_users']:
                if fct_user['machine_user_name'] == user_name:
                    user_id.append(fct_user['fct_user_id'])
        except KeyError:
            continue
        if endpoint['last_seen_fct_user_id'] in user_id:
            print(f"{user_name} is a last seen user on endpoint {endpoint['name']}, which has ID {endpoint['device_id']}.")
        else:
            print(f"{user_name} does not appear in the last seen users of any endpoint.")
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Deregistering endpoint(s) by name or ID

    If you want to deregister an endpoint because this endpoint should no longer use a license, for example, the following script does just that.

    You can add known IDs to the deregister_list or match on the name of one or multiple endpoints using the endpoint_names_list

    Deregister endpoint(s) by name or ID

    '''
    ems_deregister_endpoints.py
    Deregister named endpoint(s) or IDs using the FortiClient EMS API
    '''
    
    import json
    import requests
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    #Add the names of endpoints to this list
    endpoint_names_list = ["WIN11-CLIENT", "FCXLAB"]
    #Add known endpoint IDs you want to deregister to this list
    deregister_list = []
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    endpoints_url = f'{api_url_prefix}/endpoints/index'
    deregister_url = f'{api_url_prefix}/clients/deregister'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    deregister_data = {}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get endpoint ID with name match and add to deregister list, then format data for API call
    for endpoint in response_decoded['data']['endpoints']:
        if endpoint['name'] in endpoint_names_list:
            deregister_list.append(endpoint['device_id'])
            deregister_data = {"ids": deregister_list}
    
    #Deregister endpoint(s)
    session.post(url=deregister_url, json=deregister_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Deregister endpoint(s) by name or ID response

    {
       "result":{
          "retval":1,
          "message":"Successfully deregistered 2 endpoint(s)."
       }
    }

    Deregister endpoint(s) by user name

    If you want to deregister all endpoints where a user name is in the last seen users, use this script.

    Deregister endpoint(s) by user name

    '''
    ems_deregister_endpoint_named_user.py
    Deregister endpoint using a user name, which is also the last seen user, on the endpoint using the FortiClient EMS API
    '''
    
    import json
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    user_name = "adkevin"
    user_id = [] #This is a list, because a user can be associated with multiple endpoints
    deregister_list = []
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    endpoints_url = f'{api_url_prefix}/endpoints/index'
    deregister_url = f'{api_url_prefix}/clients/deregister'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    deregister_data = {}
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
    response_decoded = json.loads(response.content.decode('utf-8'))
    
    #Get user FortiClient ID with name match, then get endpoint ID with user ID match
    for endpoint in response_decoded['data']['endpoints']:
        #If there is no recorded user on an endpoint the fct_users key does not exist
        try:
            for fct_user in endpoint['fct_users']:
                if fct_user['machine_user_name'] == user_name:
                    #All user IDs get added to a list, because the ID can be associated with multiple endpoints
                    user_id.append(fct_user['fct_user_id'])
            #Only if the user ID is the last seen user on the endpoint, the endpoint will be deregistered
            if endpoint['last_seen_fct_user_id'] in user_id:
                deregister_list.append(endpoint['device_id'])
                deregister_data = {"ids": deregister_list}
        except KeyError:
            continue
    
    #Deregister endpoints that have the user name as the last seen user, which can be multiple
    response = session.post(url=deregister_url, json=deregister_data, headers=change_headers, verify=False, timeout=30)
    response_decoded = response.content.decode('utf-8')
    print(response_decoded)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Deregister endpoint(s) by user name response

    {
       "result":{
          "retval":1,
          "message":"Successfully deregistered 2 endpoint(s)."
       }
    }

    What about EMS Settings?

    After all this, one topic that is conspicuous by its absence is the EMS settings, i.e. the listen address, keepalives, license timeouts, etc.

    You can actually set these things using the API with the /api/v1/settings/server/set using a PATCH, but it doesn’t do anything, despite the response stating “Settings updated successfully”.

    Set EMS Settings

    '''
    ems_set_server_settings.py
    Set server data using the FortiClient EMS API
    '''
    
    import requests
    
    #Disable warnings
    requests.urllib3.disable_warnings()
    
    #Set credentials
    username = 'apiadmin'
    password = 'Start123$'
    
    #Set some variables for the API
    ems_server = '192.168.1.208'
    
    #Set all used URLs
    api_url_prefix = f'https://{ems_server}/api/v1'
    login_url = f'{api_url_prefix}/auth/signin'
    logout_url = f'{api_url_prefix}/auth/signout'
    server_settings_url = f'{api_url_prefix}/settings/server/set'
    
    #Variables for data and headers
    auth_data = {"name": f"{username}", "password": f"{password}"}
    api_headers = {"Content-type": "application/json"}
    
    server_data = {
        "chromebooks": {
            "enabled": False,
            "inactivity_timeout": 24,
            "update_interval": 300,
            "is_licensed": True,
            "service_account": "account-1@forticlientwebfilter.iam.gserviceaccount.com",
            "global_enabled": False,
            "listen_port": 8443,
            "ssl_name": "FCTEMSSERIAL.1.cert",
            "ssl_date": "2056-05-26 20:48:33",
            "chromebook_cert_id": 35,
            "acme_auto_renew": False
        },
        "scheduledBackup": {
            "scheduled_backup_enabled": False,
            "scheduled_backup_type": 1,
            "scheduled_backup_interval": 1,
            "scheduled_backup_start_time": "20:00",
            "scheduled_backup_protocol": 2,
            "scheduled_backup_remote_server_ip": None,
            "scheduled_backup_selected_days": [
                "1",
                "4"
            ],
            "scheduled_backup_password": None,
            "scheduled_backup_compress_type": "database",
            "scheduled_backup_path": "/home/ems/exchange",
            "scheduled_backup_server_type": "local",
            "scheduled_backup_remote_user": None,
            "scheduled_backup_remote_user_password": None,
            "scheduled_backup_retention_period": 15
        },
        "reset_deployment_interval": 12,
        "sws_enabled": False,
        "sws_server": None,
        "sws_cert_name": None,
        "sws_cert_date": None,
        "inv_only_reg_enforcement_type": 0,
        "pwd_changed_check_enforced": False,
        "onboarding_enforced": False,
        "user_auth_period": None,
        "fct_repackager_upload_region": "Europe",
        "endpoints": {
            "key": None,
            "keep_alive_interval": 30,
            "offline_timeout": 15,
            "tag_timeout": 1440,
            "delete_timeout": 30,
            "license_timeout": 0,
            "duplicate_onboarded_user_timeout": 7,
            "unauthed_user_timeout": 30,
            "password_lockout_attempt": 3,
            "password_lockout_period": 60,
            "ztna_token_support": True,
            "ztna_token_timeout": 1440,
            "avatar_upload_enabled": False,
            "snapshot_interval": None
        },
        "unauthed_fct_count": 1,
        "unsupported_fct_count": 0,
        "telemetry": {
            "show_fortigate_server_list": False
        },
        "ztna_cert_date_created": "2026-06-19T08:11:54.217",
        "ztna_cert_expiry_date": "2051-06-13T08:11:54.217",
        "ztna_cert_name": "default_ZTNARootCA.pem",
        "is_custom_ztna_cert": False,
        "custom_hostname": "",
        "public_address": "ems.ad.labdomain.com",
        "public_port": 443,
        "https_enabled": True,
        "https_redirect_enabled": True,
        "ssl_from_forti_care": False,
        "custom_ec_cert": True,
        "enable_persistent_connection": True,
        "installer_port_enabled": True,
        "fos_notify_server_port": 8015,
        "webserver_cert_id": 36,
        "ec_cert_id": 36,
        "auto_upgrade_enabled": True,
        "hostname": "EMS",
        "is_ip_invalid": False,
        "listen_port": 8013,
        "fqdn_enabled": True,
        "fqdn": "ems.ad.labdomain.com",
        "installer_ip": "fcems-server",
        "show_fortigate_server_list": False,
        "password_lockout_attempt": 3,
        "password_lockout_period": 60,
        "ha_alert_interval": 60,
        "acme_auto_renew": False,
        "predefined_hostname": "*,192.168.1.208",
        "https_port": 443,
        "sites_enabled": False,
        "is_fgt_connected": False,
        "login_banner": {
            "enabled": False,
            "message": ""
        },
        "ips": [
            "192.168.1.208"
        ],
        "listen_ip": "0.0.0.0",
        "installer_port": 10443
    }
    
    #Setup session, login to EMS, and set new headers with CSRF token and referer
    session = requests.Session()
    login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
    change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
    
    #Set server settings
    session.patch(url=server_settings_url, data=server_data, headers=change_headers, verify=False, timeout=30)
    
    #Perform a logout
    session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
    

    Set EMS Settings response

    {
       "result":{
          "retval":1,
          "message":"Settings updated successfully."
       }
    }

    This might be related to the fact that, if you reverse engineer it, you see that this is form-based, like authentication servers.

    Browser tools payload for EMS server settings

    A list of undocumented API endpoints

    This is a non-exhaustive list of API endpoints I noticed are missing from the official documentation while creating all of this. There are more, but I didn’t look very hard, and I didn’t bother configuring some things to check for endpoints.

    This list is valid as of 2026-07-01.

    • /api/v1/on_net_rules/index
    • /api/v1/on_net_rules/create
    • /api/v1/on_net_rules/{ID}/rules/get
    • /api/v1/on_net_rules/{ID}/rules/update
    • /api/v1/on_net_rules/{ID}/rules/delete
    • /api/v1/assignable_installers/index
    • /api/v1/assignable_installers/create
    • /api/v1/assignable_installers/{ID}/get
    • /api/v1/assignable_installers/{ID}/update
    • /api/v1/assignable_installers/{ID}/delete
    • /api/v1/group_containers/domains/index
    • /api/v1/idps/index
    • /api/v1/idps/{GUID}/delete
    • /api/v1/idps/adfs/test
    • /api/v1/idps/{GUID}/get
    • /api/v1/idps/adfs/{GUID}/update
    • /api/v1/idps/adfs/{GUID}/imported_ous
    • /api/v1/connectors/index
    • /api/v1/server_certificates/index
    • /api/v1/settings/server/get
    • /api/v1/settings/server/set
    • /api/v1/system/cloud/repackager/status
    • /api/v1/settings/server/addresses/get
    • /api/v1/admins/create
    • /api/v1/admins/{ID}/get
    • /api/v1/admins/{ID}/update
    • /api/v1/admins/{ID}/delete
    • /api/v1/profiles/{COMPONENT_TYPE}/index
    • /api/v1/profiles/{COMPONENT_TYPE}/{ID}/get
    • /api/v1/profiles/{COMPONENT_TYPE}/{ID}/delete
    • /api/v1/profiles/ztna/saas_applications
    • /api/v1/roles/index
    • /api/v1/client_certificates/group_index
    • /api/v1/client_certificates/index
    • /api/v1/client_certificates/set
    • /api/v1/oauth2_fabric_connectors/index
    • /api/v1/fabric_device_auth/{FGT_SERIAL}/update
    • /api/v1/client_certificates/delete
    • /api/v1/ztna_apps/index
    • /api/v1/troubleshoot/get
    • /api/v1/idps/{GUID}/live_navigate
    • /api/v1/system/info
    • /api/v1/endpoints/connection/donut
    • /api/v1/endpoints/management/donut
    • /api/v1/license/get
    • /api/v1/forti_care/get
    • /api/v1/logs/index
    • /api/v1/logs/count
    • /api/v1/users/local/index
    • /api/v1/users/local/create
    • /api/v1/users/local/{ID}/update
    • /api/v1/users/local/{ID}/delete
    • /api/v1/users/{ID}/endpoints/count

    Wrapping up

    A lot was covered today, but I still feel it is a light post, since most of the work was scripting and troubleshooting. I know this post comes across as negative, and I don’t feel positive about the API, but it’s not my intention to make anyone feel bad. If the documentation and the API get better because of this, I see that as a great success. If someone feels helped by this post, then I consider that an even greater success.

    Dear reader, do you feel helped?

    A shoutout to Maximilian Schiffner from Fortinet for this post. He is one of the greatest Fortinet engineers I know, and I don’t say that just because he’s Austrian. I got the idea for this post because of a single sentence in an email he wrote.

    And since this is the end and EMS was the topic, why not read about how you can use the certificate management capabilities of FortiClient EMS for VPN, full SSL/TLS inspection and 802.1X or how to connect to an HA EMS cluster from a FortiGate without an external load balancer? The EMS journey doesn’t stop!

  • 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.