Skip to content

Repository files navigation

FortiScan

Answers one question across four independent sources: is this FortiGate's IP Geography DB actually up to date?

  • FortiGate over SNMP — walks fgLicVersionTable for 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 versions and diffs it field-by-field against the SNMP data, to prove the two agree.
  • FortiManager over JSON-RPC (optional)/um/device/object reports both current_version and latest_version per FortiGuard object.
  • FortiGuard public page (on by default) — the newest release Fortinet has actually published. --no-fortiguard to 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.

Example output

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.

Example console output showing the FortiManager proxy license status, FortiGuard published versions, and the final cross-source reconciliation verdict

The version-spelling problem

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.

Layout

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.

Setup

Requires Python 3.9+.

  1. 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
  2. Install dependencies:

    pip install -r requirements.txt

Configuration

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   # gitignored

Edit it with your FortiGate/FortiManager hosts. Anything defined there overrides the matching default.

2. Environment variablesFGT_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.py

3. 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.py is 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.

What this tool never commits

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

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

Usage

Run with the built-in defaults:

python3 main.py

Or override host/port/community for a one-off run:

python3 main.py --host 10.0.0.1 --port 161 --community public

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

SNMPv3

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"

Cross-checking against the CLI (--compare-cli)

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 admin

You'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.

FortiManager cross-check (--with-fmg)

If a FortiManager also manages this FortiGate, --with-fmg queries it over its JSON-RPC API (fortiscan/fortimanager/api.py) for two things:

  1. /um/device/object (JSON-RPC exec, {"flags": 0}) — FortiGuard object versions. Note the nesting: the response is a list of managed devices (by serial), each holding its own object_version list, 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 by objid (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.

  2. (optional) a managed device's license status, if --fmg-device is given — the same /api/v2/monitor/license/status a FortiGate exposes directly, reached instead through FortiManager's sys/proxy/json proxy.

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-01

You'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.

FortiGuard published version (on by default)

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 admin

It is the tool's only outbound internet call, so disable it where that isn't allowed:

python3 main.py --no-fortiguard

The 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.345

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

The reconciliation verdict

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.

Raw JSON of everything (--dump-raw)

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

Structure is meta / fortigate.snmp / fortigate.ssh / comparison / fortimanager.payloads / fortiguard / reconciliation, with sections present only for the sources that ran.

Generating a handoff report (--report)

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

With no path given, it writes fortiscan_report_<host>_<timestamp>.md in the current directory.

Deactivating the virtual environment

deactivate

About

Gathers and correlates package version data between FortiGuard, FortiManager and FortiGate.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages