Answers one question across four independent sources: is this FortiGate's IP Geography DB actually up to date?
- FortiGate over SNMP — walks
fgLicVersionTablefor every license/signature database (version, contract expiry, last update result), so the data is pollable by a monitoring system instead of screen-scraped over SSH. - FortiGate over SSH (optional) — runs
diagnose autoupdate versionsand diffs it field-by-field against the SNMP data, to prove the two agree. - FortiManager over JSON-RPC (optional) —
/um/device/objectreports bothcurrent_versionandlatest_versionper FortiGuard object. - FortiGuard public page (on by default) — the newest release Fortinet
has actually published.
--no-fortiguardto skip.
All four are normalized to a common major.minor and reconciled into a single
verdict, e.g.:
VERDICT: FortiGate is BEHIND by 304 release(s) (3.41 vs 3.345)
See IP_GEO_DB_SNMP_HANDOFF.md for the full explanation of where this data lives in SNMP, why the table is walked instead of using a fixed row index, and how the walk works.
The tail end of a run with --with-fmg: the FortiManager license-status
proxy, the FortiGuard published-version lookup (picking the FortiOS 6.2+ feed
over the legacy one), and the final cross-source reconciliation verdict.
The same version is printed three different ways, which makes naive string comparison useless:
| Source | Spelling | Normalized |
|---|---|---|
| FortiGate SNMP / CLI | 3.00041 (minor zero-padded to 5) |
3.41 |
FortiManager /um/device/object |
00003.00345 (both halves padded) |
3.345 |
| fortiguard.com | 3.345 (unpadded) |
3.345 |
fortiscan/versions.py parses both halves as integers so all three become
comparable. Everything in the tool compares versions through it — never by
string equality.
Code is organized by device, then by access method — so adding e.g. a FortiGate REST API collector, or a FortiAnalyzer, is a new module rather than a new branch in a growing script:
main.py thin entrypoint
fortiscan/
├── config.py shared defaults/constants
├── versions.py major.minor normalization across all sources
├── cli.py argument parsing, validation, credential prompts
├── runner.py concurrent collection across all sources
├── app.py top-level flow (parse → collect → render → report)
├── fortigate/
│ ├── snmp.py fgLicVersionTable walk
│ └── ssh.py diag command scrape + parse
├── fortimanager/
│ └── api.py JSON-RPC client (/um/device/object, license proxy)
├── fortiguard/
│ └── web.py published versions from fortiguard.com
└── report/
├── compare.py CLI vs SNMP correlation
├── reconcile.py cross-source version reconciliation
├── console.py terminal rendering
├── markdown.py --report document
└── serialize.py --dump-raw JSON
All enabled sources are collected concurrently. SNMP, SSH, the FortiManager
API, and the FortiGuard lookup are independent, so runner.py launches them
together and awaits them as a group — total wall time is the slowest source,
not the sum. Each run prints a timing table showing this. Collectors return
data rather than printing, so console output stays in a fixed order regardless
of which source finishes first, and a failure in one source is reported without
costing you the others' results.
Requires Python 3.9+.
-
Create and activate a virtual environment:
python3 -m venv venv
Activate it:
- macOS/Linux:
source venv/bin/activate - Windows (PowerShell):
venv\Scripts\Activate.ps1 - Windows (cmd.exe):
venv\Scripts\activate.bat
- macOS/Linux:
-
Install dependencies:
pip install -r requirements.txt
fortiscan/config.py ships with placeholder defaults only — no real hosts
or credentials are committed to this repo. Supply your own by any of three
routes, in increasing precedence:
1. A local config file (easiest for a fixed environment):
cp local_config.example.py local_config.py # gitignoredEdit it with your FortiGate/FortiManager hosts. Anything defined there overrides the matching default.
2. Environment variables — FGT_HOST, FGT_COMMUNITY, FGT_PORT,
FMG_HOST, FMG_PORT, FMG_ADOM:
export FGT_HOST=10.0.0.1
export FGT_COMMUNITY=your-community-string
python3 main.py3. Command-line flags — --host, --community, --fmg-host, … These
win over everything else, and are the right choice for one-off runs.
The SNMP community string is a credential. Prefer the environment variable or a flag over writing it into a file, and note that
local_config.pyis gitignored for exactly this reason.
The FortiGate must have SNMP enabled with this host/community (or SNMPv3 user, see below) allowed to query it — read-only access is sufficient.
.gitignore excludes local_config.py, --dump-raw JSON files, and
generated fortiscan_report_*.md. Those outputs contain real hostnames,
device serial numbers, license entitlements and update history — review
before sharing any of them.
--snmp-timeout (default 2.0s) and --snmp-retries (default 3) control each
request. --snmp-delay (default 0) paces successive GETNEXT requests — raise
it to ~0.1 if the walk starts fine but the device stops answering partway
through, which some FortiOS builds do when rate-limiting rapid SNMP polling.
A walk that ends early still reports the rows it did get, with a warning.
Run with the built-in defaults:
python3 main.pyOr override host/port/community for a one-off run:
python3 main.py --host 10.0.0.1 --port 161 --community publicThis prints an explanation of the OID/table being queried, the full
fgLicVersionTable (every license/database FortiOS tracks), and then calls
out whichever row matched "IP Geography DB" on this run.
Run python3 main.py --help for the full list of options.
Pass --v3 plus a security user; --v3-auth-key/--v3-priv-key are only
needed for authNoPriv/authPriv (omit both for noAuthNoPriv):
python3 main.py --host 10.0.0.1 --v3 \
--v3-user monitor \
--v3-auth-proto SHA256 --v3-auth-key "authpassphrase" \
--v3-priv-proto AES --v3-priv-key "privpassphrase"To prove the SNMP output matches the FortiGate CLI 1:1, --compare-cli SSHes
into the device (via asyncssh), runs config global followed by the diag
command (default: diag debug autoupdate versions — if your FortiOS build
rejects that, pass --cli-command "diagnose autoupdate versions" instead),
parses the resulting text blob into the same shape as the SNMP rows, and
diffs every field per license/database entry:
python3 main.py --host 10.0.0.1 --community public \
--compare-cli --ssh-user adminYou'll be prompted for the SSH password if --ssh-password isn't supplied.
Each entry is printed as [OK]/[DIFF!] per field, with a final
ALL FIELDS MATCH / DIFFERENCES FOUND summary line. Entries that exist in
the CLI output but don't fit the table's field schema (e.g. Modem List,
FDS Address) are reported as skipped rather than false failures.
If a FortiManager also manages this FortiGate, --with-fmg queries it over
its JSON-RPC API (fortiscan/fortimanager/api.py) for two things:
-
/um/device/object(JSON-RPCexec,{"flags": 0}) — FortiGuard object versions. Note the nesting: the response is a list of managed devices (by serial), each holding its ownobject_versionlist, and some entries (the FortiManager itself) carry no objects at all:[{"serial": "FG100D0000000001", "status": "pending", "update_time": 1786737704, "object_version": [ {"current_version": "00003.00345", "latest_version": "00003.00345", "status": "up-to-date", "license": "valid", "license_type": "FMWR", "obj_desc": "GeoIP Country Level ", "objid": "07002000IPGO00000", "obj_fmgi": {"ext_desc": "IP Geo DB", "product": "FortiGate"}}]}, {"serial": "FMVM000000000001", "flags": 0}]Because each object carries
latest_version, this endpoint alone can say whether it is current — every object gets a computed drift column. Output is grouped by device, and the IP Geography record is located byobjid(07002000IPGO00000) or description, never by list position.Device selection: the tool also reads
fnSysSerial(1.3.6.1.4.1.12356.100.1.1.1.0) over SNMP and matches it against these serials, so with several managed FortiGates the reconciliation compares the one actually being polled. The match is reported in the verdict; a best-effort failure to read the serial just falls back to the first device reporting objects. -
(optional) a managed device's license status, if
--fmg-deviceis given — the same/api/v2/monitor/license/statusa FortiGate exposes directly, reached instead through FortiManager'ssys/proxy/jsonproxy.
Auth is username/password by default (a /sys/login/user session, logged
out when done) or an API key via --fmg-api-key (sent as an Authorization: Bearer header, no login round-trip):
# username/password
python3 main.py --with-fmg --fmg-host 192.0.2.2 --fmg-user admin
# API key instead
python3 main.py --with-fmg --fmg-host 192.0.2.2 --fmg-api-key "..."
# also pull a specific managed device's license status through the FMG proxy
python3 main.py --with-fmg --fmg-host 192.0.2.2 --fmg-user admin \
--fmg-adom root --fmg-device FGT-Branch-01You'll be prompted for the FortiManager password if --fmg-password isn't
supplied. TLS verification is off by default (--fmg-verify-ssl to enable)
since these are commonly self-signed on lab/internal FortiManagers.
Looks up what Fortinet has actually published on https://www.fortiguard.com/services/ipge and reconciles it against the FortiGate and FortiManager figures. This runs by default — no flag needed:
python3 main.py --with-fmg --fmg-user adminIt is the tool's only outbound internet call, so disable it where that isn't allowed:
python3 main.py --no-fortiguardThe page lists more than one database generation:
2.345 IP Geolocation Database Update
3.345 IP Geolocation Database for FortiOS 6.2+ Update <-- selected
These are different generations, not a newer/older pair, so the row whose major version matches what the device is running is selected rather than simply the largest number.
For hosts with no internet access, supply the value directly (this skips the web request entirely):
python3 main.py --fortiguard-version 3.345Other flags: --with-fortiguard (explicit enable, for clarity in scripts),
--fortiguard-url to point at a different page, and --fortiguard-insecure
to skip TLS verification behind an inspecting proxy. If the lookup fails, the
run continues and the verdict falls back to FortiManager's latest_version.
Whenever at least one source reports a version, a reconciliation section is printed last (and leads the Markdown report):
FortiGate SNMP (fgLicVersionTable) reported=3.00041 normalized=3.41 BEHIND by 304 release(s)
FortiGate CLI (diag autoupdate versions) reported=3.00041 normalized=3.41 BEHIND by 304 release(s)
FortiManager current_version (/um/device/object) reported=00003.00345 normalized=3.345 up-to-date
FortiManager latest_version (/um/device/object) reported=00003.00345 normalized=3.345 up-to-date
FortiGuard published (fortiguard.com) reported=3.345 normalized=3.345 up-to-date
Device serial : SNMP=FG100D0000000001 FMG=FG100D0000000001 (matched)
VERDICT: FortiGate is BEHIND by 304 release(s) (3.41 vs 3.345)
"Newest available" is taken from FortiGuard when present, otherwise from
FortiManager's latest_version. A major-version difference is reported as a
different database generation rather than a "behind by N", since those aren't
comparable. Note the FortiManager figures describe what FMG holds — a
FortiGate can sit far behind the FortiManager managing it, which is exactly
the drift this surfaces.
Sources that weren't asked for are labelled not queried (with a summary
line beneath the table), distinct from failed for one that was asked and
errored, and no value for one that answered without a version — so a blank
row is never mistaken for a lookup failure.
Writes every source's collected data to one JSON file — all SNMP rows, the SSH/CLI output both raw and parsed, the computed comparison, every FortiManager API payload, the FortiGuard entries, and the reconciliation, plus per-source timings, errors, and warnings:
python3 main.py --compare-cli --ssh-user admin --with-fmg --fmg-user admin \
--dump-raw all_data.jsonStructure is meta / fortigate.snmp / fortigate.ssh / comparison /
fortimanager.payloads / fortiguard / reconciliation, with sections
present only for the sources that ran.
--report writes an easy-to-read Markdown report, led by the reconciliation
verdict and followed by the IP Geography highlight, the full table, and
whichever of the --compare-cli / --with-fmg / FortiGuard sections
were collected:
python3 main.py --report # auto-named file
python3 main.py --report handoff.md # explicit path
# everything, in one pass
python3 main.py --compare-cli --ssh-user admin \
--with-fmg --fmg-host 192.0.2.2 --fmg-user admin \
--report handoff.mdWith no path given, it writes fortiscan_report_<host>_<timestamp>.md in the
current directory.
deactivate