FMG API From Scratch Part 4: A Service Account That Can Do the Job and Nothing Else

Part 3 built a client that logs in, reads and logs out. It logged in as someone. This part is about who that someone should be, and how to prove it can’t do more than you meant.

The reason is the last row of the table in Part 1: FortiManager concentrates risk. A leaked FortiGate token costs you a device. A leaked FortiManager credential costs you whatever that account can reach. So the account is not an afterthought to the script. It is the blast radius of every mistake the script will ever make.

The code for this part is in the fmg-api-from-scratch repo at tag part-04.

Four fences

“Least privilege” is vague until you say what you are fencing. For an API account I use four:

FenceQuestion it answersWhere it is set
WhoWhich source address may log in as this account?Trusted hosts on the administrator
WhereWhich ADOMs can it see?ADOM access on the administrator
WhatWhich features can it use, and how far (none, read, read-write)?The administrator’s admin profile
What leaksWhat does it get back, and where does that end up?Your own code: logging and redaction

The first three are FortiManager settings. The fourth is yours, and it is the one people forget.

Session login or an API key?

Fortinet’s API Best Practices guide offers two shapes of account, and both need JSON API access enabled:

Regular administrator, session loginREST API administrator, API key
How it authenticatesexec /sys/login/user, then a session token on each callA permanent key generated by FortiManager
Login and logout callsYesNo
What a leak costsA session token that expires, but a password that doesn’tA key that doesn’t expire until you regenerate it
What you have to storeA passwordA key

Token-based API administrators arrived in FortiManager 7.2.2, so on an older box the session account is, as far as I know, the only shape you have. On 7.6 you can choose.

I use the session account, as in Part 3, because that is where the worked examples are and the change to a key later touches one method. The security difference is smaller than it looks: either way you store one long-lived secret. The fences matter more than the choice.

Building the account

Two objects, made in the FortiManager GUI in this order:

  1. An admin profile. Start from nothing and add. The mistake to avoid is cloning a broad profile and removing things, because what you forget to remove stays. Every feature starts at none, and you raise one only when a specific call fails for want of it.
  2. An administrator. A regular local account, with JSON API access set to read-write (it needs to write), the profile above, ADOM access limited to the one ADOM the tool works in, and a trusted host of the machine the scripts run from, as a single-host /32.

Then read it back through the API rather than trusting the GUI’s description. Reading with verbose=True (Part 3) returns the enumerations as words. This is the account from my lab, trimmed, with the names and address replaced:

{
  "userid": "svc-push",
  "profileid": "PUSH-API",
  "adom-access": "specify",
  "adom": [{ "adom-name": "LAB" }],
  "rpc-permit": "read-write",
  "trusthost1": ["192.0.2.6", "255.255.255.255"],
  "trusthost2": ["255.255.255.255", "255.255.255.255"],
  "login-max": 32,
  "two-factor-auth": "disable",
  "user_type": "local"
}

Two things to know about reading it:

  • 255.255.255.255 255.255.255.255 means an unused trusted-host slot, not a host. There are ten slots, and the nine empty ones look exactly like this. The single real entry is trusthost1, with a /32 mask.
  • JSON API access is on the administrator, not the profile. The profile has an rpc-permit field too, and in my lab it reads none while the account works perfectly. The account’s own setting is the one that matters.

Audit it with the API

A checklist you have to remember to run is a checklist you’ll skip, so I made the audit a script. fmg_audit.py takes the records FortiManager returned and hands back findings. It makes no network calls, which is what lets it be tested with plain dictionaries. examples/check_scope.py does the reading and printing.

The rules, and why:

CheckFails or warns whenWhy
ProfileThe account uses Super_UserAn API account should never be a super user
ADOM scopeAccess isn’t specify, or more than one ADOMThe blast radius is the ADOM list
Trusted hostsNone set, or a subnet where a host would doOtherwise the password is the only fence
Sensitive permissionsAny of a short list isn’t noneSee below
API loggingjsonapi-log is disabledMakes the logging choice visible. Disabled can be the right answer: see “Logging” below
LockoutNo threshold setGuessing isn’t slowed
API idle timeoutLonger than 30 minutesA stolen session stays useful

The short list of permissions a push account shouldn’t hold: super-user-profile, adom-admin, system-setting, term-access, the FortiGuard centre permissions and device-revision-deletion. My reasoning: none of them is needed to write configuration, and each would matter a great deal to an attacker.

The audit is unit-tested with fixtures shaped like the real records, with a test for each of the main failures, so a rule can’t silently stop working. I checked that by switching individual rules off and confirming the suite fails each time.

Here is the real output from my lab, run with an extra check that tries to read a device list from another ADOM (names replaced):

Audit of svc-push:
  [OK  ] ADOM scope: 1 ADOM(s): LAB
  [OK  ] trusted hosts: 1 single host(s)
  [INFO] JSON API access: rpc-permit is 'read-write'
  [WARN] profile permissions: beyond a push account's needs: system-setting=read-write, term-access=read-write, fgd_center=read-write, fgd-center-advanced=read-write, fgd-center-fmw-mgmt=read-write, fgd-center-licensing=read-write, device-revision-deletion=read-write, write-passwd-access=all
  [WARN] API logging: jsonapi-log is disabled: API calls are not logged
  [INFO] lockout: 3 failures, by ip
  [WARN] API idle timeout: 3600s is longer than 1800s
  [OK  ] read outside scope (root): refused

The fences that matter most, ADOM and source address, are fine. Everything that could go wrong sits in the profile. (One entry in that permissions list, write-passwd-access=all, turned out to be a false alarm in my audit. The story is a few sections down.)

What it found in my own lab

The audit warned that my account’s profile was far broader than the tool needs. It had read-write on system settings, terminal access, the FortiGuard centre and revision deletion.

That was my doing. While troubleshooting an install failure (Part 8 has the story), I widened the profile to rule permissions out as the cause. They weren’t the cause. The real problem was the sequence of calls. I fixed that and never narrowed the profile back.

The lesson is about method, not embarrassment. A permission change made while debugging is a hypothesis. Write down what you changed, test whether it fixed anything, and if it didn’t, put it back straight away. The audit is the backstop for when you don’t.

Being a permission problem is easy to assume, because the failure message for a missing session and a missing permission is the same -11 No permission for the resource from Part 3. Rule out the session first.

Narrowing without breaking the tool

I don’t remove permissions and hope. The safe order:

  1. Write down the current values, so that rolling back is a set of commands you already have.
  2. Set the clearly unneeded permissions to none. In the GUI you can clone the profile and assign the clone, so that rollback is one field. I only had SSH access at the time, so I edited the profile in place from the FortiManager CLI instead, with the old values noted.
  3. Run the whole pipeline and keep the results.
  4. Repeat for the next group, one group at a time, so a failure points at what you just removed.

From the FortiManager CLI, the change is one block:

config system admin profile
    edit PUSH-API
        set system-setting none
        set term-access none
        set fgd_center none
        set fgd-center-advanced none
        set fgd-center-fmw-mgmt none
        set fgd-center-licensing none
        set device-revision-deletion none
    next
end

I removed seven permissions in one batch. That is defensible here only because none of them has anything to do with pushing configuration, so a failure would have been surprising. After the change I ran the same checks again:

CheckBeforeAfter
Read: version, ADOM, devices, a device’s DNSWorksWorks
Write: add, update, set and delete a script objectWorksWorks
Read the account’s own administrator recordWorksRefused, -11
Read the account’s own profileWorksRefused, -11
Read global and admin settingsWorksRefused, -11
List every administrator on the FortiManagerWorksRefused, -11
Read a device list from another ADOMRefusedRefused

Everything the tool needs still works. What stopped working is exactly what an attacker holding this credential would have liked: reading who else is an administrator, and reading how the box is configured.

Those checks used small probe scripts. The real test is the finished tool, so I then ran its read-only commands with the narrowed account: listing the managed devices, listing the ADOM’s variables, and reading the log of the last script run against a device. All three worked first time. The log shows the script that ran and its result:

Starting log (Run on database)

 config system dns
 set primary 192.0.2.53
 ...
 end
Running script(system_dns__<device>) on DB success

The variable listing also showed a small point in the redaction’s favour. One variable is of the password type, and the tool prints its default as ****** rather than the value. Part 6 covers why FortiManager has secret-typed variables at all.

One consequence to plan for: the account can no longer read its own record or profile, which is exactly what the audit needs. That is a good sign, not a bug. The audit reports “not readable by this account” and carries on, and for a full check you run it with a separate read-only login, or as an administrator, using the script’s --env-file and --user options.

A false alarm in my own audit

My first version of the audit also flagged write-passwd-access=all, from its name a permission to set other administrators’ passwords. I tried to set it to none along with the others, and the FortiManager CLI refused it:

command parse error before 'write-passwd-access'

A setting the CLI won’t accept isn’t one you can turn off. The API reports the field with a value even when the feature is off. My explanation was that it only applies when the separate change-password permission is enabled, and is otherwise a dormant default. I then checked the profile from the CLI:

fortimanager # get system admin profile PUSH-API | grep -i passw
change-password     : disable

change-password is disable, which fits: the account can’t change passwords, so the all beside write-passwd-access has nothing to act on. I haven’t found Fortinet documentation that says so in as many words, so I’d call it well supported rather than certain.

So I changed the audit to report write-passwd-access only when change-password is enabled, and added a test for each case. Two lessons: an audit rule that ignores how settings depend on each other will cry wolf, and a fix isn’t finished until you have tried it against the real system. The refusal is what showed me the rule was wrong.

Prove the fences hold

Configuration that looks right is not evidence. Test each fence by trying to cross it.

Where. Ask for a device list in an ADOM the account isn’t allowed in:

FMGError code=-11 msg=No permission for the resource url=/dvmdb/adom/root/device

Refused, which is what should happen. The same account sees only its own ADOM in /dvmdb/adom.

What. After narrowing, list the administrators and read the global settings. Both are refused with the same -11.

Who. Log in as the account from a host that isn’t in its trusted hosts. The stdlib-only examples/untrusted_login.py asks for the password without echo, makes exactly one login attempt and prints the result. I ran the same script, with the same correct password, from two machines:

SourceHTTP statusJSON statusSession
Trusted host200code 0, OKIssued, then logged out
Another host on the network200code -22, Login failNone

Three things to take from that:

  • A refused login still returns HTTP 200. The refusal is only in the JSON status, so a client that checks the HTTP status alone would think it had logged in.
  • FortiManager doesn’t say which fence stopped you. I then tried a deliberately wrong password from the trusted host, and got exactly the same -22 Login fail. From the response, an attacker can’t tell “wrong password” from “right password, wrong place”, which is the behaviour you want.
  • FortiManager’s own event log doesn’t say either, but it does record where the attempt came from. The two failures are logged with the same event ID, the same description (User login/logout failed), the same alert level and the same message wording: user 'svc-push' login failed from JSON(<address>). The only difference is the address, which is the source as FortiManager saw it. That is useful the other way round: someone watching the log can see a failed login from an address that doesn’t belong, but the log won’t tell them whether the password or the address was the problem.

Do this test from a host you can afford to lock out, never from your automation host. The next section explains why.

Lockout. The lab’s global settings show a lockout after 3 failed attempts, and the lockout method is by source IP. That has a sharp consequence: three bad logins from the automation host lock out that whole host, including a human administrator working from it. It is why Part 3’s client stops at the first failed login instead of retrying. A retry loop with a wrong password is a self-inflicted lockout. It is also why the test above makes a single attempt, and why a failed attempt from the second machine counts against that machine’s address.

Logging: decide what you are willing to write down

Fortinet’s guide mentions a jsonapi-log setting with the values all, disable, request and response. In my lab it started as disable, so nothing recorded what the API account did.

My first reasoning was that request is the safe choice: it records what was asked, and only response can contain the secrets described in the next sections. That was wrong, and I only found out by testing it. I set request, made a few calls, then exported the log from System Settings > Event Logs and read it:

  • The login request was logged with its parameters, including the passwd field, in the clear.
  • Later requests carried the full session token.

Anyone who can read that log, or an export of it, has the account’s password and a replayable session while it lasts. Request logging is not the cautious option, because the login call is itself a request.

What I did about it:

  1. Set jsonapi-log back to disable.
  2. Rotated the account’s password, because it had already been written to the log and to the export I downloaded.
  3. Deleted the exported file.

Two related things worth knowing:

  • Even with jsonapi-log disabled, FortiManager’s event log records object changes, and the entry for a script object contains the script’s content. Treat the event log as sensitive, and never put a secret in a script body. The entries for a configuration change made through the API also carry the account name and the source address of the API client, with the old value next to the new one, so the Event Log is a useful independent record of what the account did.
  • Commands typed at the FortiManager CLI are logged there too.

So my recommendation is the reverse of where I started. Leave jsonapi-log disabled day to day. If you need it while investigating one problem, turn on request briefly, expect it to capture the password and tokens, then turn it off, rotate the credential and delete any exports. A throwaway test account makes that easy, because the password you rotate isn’t the one your tools use.

The audit still reports the disabled setting, so the choice is visible instead of accidental.

One related setting, the API idle timeout, I have since lowered from 3600 to 900 seconds. I haven’t tested what that does to a long-running script, so I can’t tell you whether 900 is safe for yours. If a long install or a slow task polls for several minutes, check that the session survives before you rely on a short timeout.

TLS: stop disabling verification

Part 3 mentioned verify=False and moved on. Here is the honest version.

A default FortiManager serves a certificate it made for itself. On mine:

  • The subject and the issuer are the same, so it is self-signed.
  • The common name is the device’s serial number.
  • It has no Subject Alternative Name.
  • It is valid until 2038.

That combination means no ordinary verification can pass. There is no CA to trust, and with no Subject Alternative Name nothing matches an IP address, so even adding the certificate to the trusted bundle fails on the hostname check. My default-verification test fails as it should:

Could not talk to FortiManager: HTTPSConnectionPool(host='<fmg>', port=443): Max retries exceeded with url: /jsonrpc (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] ...

You have three options, best first:

OptionHowTrade-off
Install a proper certificateIssue one with a Subject Alternative Name for the name or address you connect to, from your own CA, and configure FortiManager to serve itCorrect, and a little work. Then FMG_CA_BUNDLE points at your CA
Pin the fingerprintTell the client the exact certificate to expect, by its SHA-256 fingerprintWorks with the self-signed certificate. Breaks, on purpose, when the certificate changes
Disable verificationverify=FalseAnyone on the path can impersonate the FortiManager and read the password you send

For a lab I pin. The client takes a fingerprint:

with FMGClient("fmg.example.internal", fingerprint="AA:BB:CC:...") as fmg:
    fmg.login(user, password)

Get the value from the certificate the server presents:

openssl s_client -connect <fmg>:443 </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256

Two cautions. First, fetching a fingerprint over the network you’re worried about is trust on first use, so compare it with the certificate details shown by the FortiManager itself. Second, when the certificate is renewed the fingerprint changes and the client refuses to connect until you update it. That’s the pin doing its job.

Implementation-wise, it is a small requests adapter that hands the fingerprint to the connection layer (urllib3’s assert_fingerprint):

class _PinnedAdapter(HTTPAdapter):
    """Accept exactly one certificate, identified by its SHA-256 fingerprint."""

    def __init__(self, fingerprint: str, **kwargs: Any):
        self._fingerprint = fingerprint
        super().__init__(**kwargs)

    def init_poolmanager(self, *args: Any, **kwargs: Any) -> None:
        kwargs["assert_fingerprint"] = self._fingerprint
        super().init_poolmanager(*args, **kwargs)

    def cert_verify(self, conn: Any, url: str, verify: Any, cert: Any) -> None:
        # requests lets REQUESTS_CA_BUNDLE override a session's verify=False,
        # so switch chain checking off here, where nothing can override it.
        super().cert_verify(conn, url, False, cert)

The cert_verify override is there because of a bug I hit while testing: on a machine with REQUESTS_CA_BUNDLE set in the environment, requests ignored verify=False on the session and rejected the certificate anyway. The pin is the only check, and this is where it is enforced.

I tested it three ways against the real FortiManager, and all three behaved:

SettingResult
Correct fingerprintLogs in and reads normally, with no warning
Wrong fingerprint (all zeros)Refused: Fingerprints did not match
Default verificationRefused: certificate verify failed

The repo tests do the same against a throwaway local TLS server with a self-signed certificate, a serial-number common name and no Subject Alternative Name. They need the openssl command to make that certificate, and skip themselves if it isn’t installed (on Windows, for example). They also cover a colon-separated upper-case fingerprint, a malformed one, and a pinned login with REQUESTS_CA_BUNDLE set. I broke the pin on purpose, once by dropping the fingerprint assertion and once by removing the cert_verify override, and the suite failed both times.

Keep the secret in one place

The credentials live in .env, which is in .gitignore, with an .env.example showing the shape:

chmod 600 .env
ls -l .env

The rules I follow:

  • Never on a command line. It lands in shell history and the process list. Read it from .env, or prompt with getpass.
  • Never in the repo. Before every push, run a secret scan over what is about to be published.
  • Rotate after any exposure, including a log that captured it or pasting output somewhere you shouldn’t have. Change it on the FortiManager and in .env.
  • Know the gap. A plaintext .env on the jumpbox is still a plaintext password. Locking the file down limits who can read it, and a real secrets store is the proper answer.

Responses leak too

The fourth fence. Routine, read-only calls return credential material you didn’t ask for:

  • A device list returns each device’s encrypted admin password.
  • An admin profile returns an encrypted key, as a list beginning with "ENC".

Encrypted is not public. These values shouldn’t end up in logs, in git or in a chat window. So the repo has a redact() helper, and it works two ways on purpose:

_SECRET_KEY = re.compile(r"pass|psk|secret|private|token|key|session|cookie", re.I)


def _looks_encrypted(value: Any) -> bool:
    """FortiManager marks encrypted values with an 'ENC' prefix."""
    if isinstance(value, str):
        return value.startswith("ENC ") and len(value) > 12
    if isinstance(value, list) and value:
        return value[0] == "ENC"
    return False

A value is masked if its key name looks secret or its value looks encrypted. I learned to do both the hard way. My first probe masked by key name only, and it let the profile’s encrypted key straight through because the field was called datamask-key and my pattern didn’t include key. A name list will eventually miss one. The value shape is the backstop.

It errs towards over-masking, so a few harmless fields are hidden too (force-password-change, for one). For output you might paste somewhere, that is the right way round.

Checklist

  • A dedicated account, never a person’s login
  • Profile built up from none, not cut down from something broad
  • ADOM access limited to specify, with one ADOM
  • Trusted host set to a single /32
  • Read the account back through the API, and audit it with a script
  • Try to cross each fence, and record the refusal, from a host you can afford to lock out
  • Check the JSON status of a login, not the HTTP status
  • Any permission change made for debugging is written down, tested, and reverted
  • Narrow in small batches with the old values written down, and re-run the checks each time
  • Run the finished tool, not only probes, after narrowing
  • Test an audit rule against the real system, not only against fixtures
  • TLS verification pinned or properly issued, never disabled
  • Credentials in a 0600 .env, out of git, never on a command line
  • Raw responses redacted before they touch a log
  • jsonapi-log left disabled, because request logging captures the login password and session tokens. Any time you enable it, rotate afterwards
  • Rotate after any exposure

What comes next

The account is fenced and the client is careful. Part 5 turns to discovery: reading ADOMs, devices and objects, and finding the endpoints the documentation doesn’t cover by watching what the FortiManager GUI sends.

Sources: