Security · python · 34 copies
FMC Unused Object Finder
Finds objects in Cisco Secure Firewall Management Center that no policy references, exports them to CSV, and cross-checks its answer against FMC's own unusedOnly filter. Exports what the GUI will not, covers the classes the native filter does not, and flags the dangerous case where FMC calls an object unused but a policy still references it.
- Cisco Secure Firewall Management Center
pip install "requests>=2.28"#!/usr/bin/env python3
"""
FMC Unused Object Finder
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/fmc-unused-objects
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "requests>=2.28"
"""
import argparse
import csv
import json
import os
import re
import sys
import time
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# FMC accepts 120 GET requests per minute from one IP. Two per second with a
# little headroom keeps a large estate inside that without thinking about it.
RATE_PER_SEC = 1.8
# Object classes to inventory: (api path, human label, covered by the native
# unusedOnly filter?). The third column is the whole point of this tool - the
# native filter is documented for network addresses only, so everything with
# False is a gap FMC does not fill.
OBJECT_CLASSES = [
("networks", "network", True),
("hosts", "host", True),
("ranges", "range", True),
("fqdns", "fqdn", True),
("networkgroups", "network group", False),
("protocolportobjects", "port", False),
("icmpv4objects", "icmpv4", False),
("portobjectgroups", "port group", False),
("urls", "url", False),
("urlgroups", "url group", False),
("vlantags", "vlan tag", False),
("vlangrouptags", "vlan group", False),
("securityzones", "security zone", False),
]
# Fields inside a rule that can name an object. FMC nests them as
# {"objects": [...], "literals": [...]}.
RULE_REF_FIELDS = [
"sourceNetworks", "destinationNetworks", "sourcePorts", "destinationPorts",
"sourceZones", "destinationZones", "urls", "vlanTags", "originalSource",
"originalDestination", "translatedSource", "translatedDestination",
"originalPort", "translatedPort", "sourceDynamicObjects",
"destinationDynamicObjects", "interfaceInOriginalDestination",
"interfaceInTranslatedSource",
]
NOT_CHECKED = [
"FlexConfig objects and FlexConfig policies",
"Remote access and site-to-site VPN topologies",
"Platform settings, health policies and variable sets",
"Objects referenced only by a device's local configuration outside FMC",
"Identity policies, SSL policies and DNS policies",
"Anything referenced by an external system through the API",
]
class Throttle:
def __init__(self, per_sec):
self.interval = 1.0 / per_sec
self.last = 0.0
def wait(self):
delta = time.time() - self.last
if delta < self.interval:
time.sleep(self.interval - delta)
self.last = time.time()
class FMC:
"""FMC REST client: token auth, throttling, pagination.
Token lifetime is 30 minutes. On a large estate a full run can exceed
that, so a 401 triggers one re-authentication rather than failing.
"""
def __init__(self, host, user, password, verify=True, save_dir=None):
import requests
import urllib3
self.requests = requests
self.host = host
self.base = "https://%s" % host
self.user = user
self.password = password
self.verify = verify
self.save_dir = save_dir
self.throttle = Throttle(RATE_PER_SEC)
self.calls = 0
if not verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.s = requests.Session()
self.s.verify = verify
self._auth()
def _auth(self):
r = self.s.post(
self.base + "/api/fmc_platform/v1/auth/generatetoken",
auth=(self.user, self.password), timeout=60,
)
if r.status_code == 401:
raise SystemExit("ERROR: %s rejected the credentials (401)." % self.host)
if r.status_code == 429:
raise SystemExit(
"ERROR: too many active tokens for this user. FMC allows a "
"limited number at once; wait for them to expire or use a "
"dedicated API account.")
r.raise_for_status()
tok = r.headers.get("X-auth-access-token")
if not tok:
raise SystemExit("ERROR: FMC did not return an access token.")
self.s.headers.update({"X-auth-access-token": tok})
self.default_domain = r.headers.get("DOMAIN_UUID")
def get(self, path, params=None, retry=True):
self.throttle.wait()
r = self.s.get(self.base + path, params=params or {}, timeout=120)
self.calls += 1
if r.status_code == 401 and retry:
self._auth()
return self.get(path, params, retry=False)
if r.status_code == 429:
time.sleep(20)
return self.get(path, params, retry=False)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def get_all(self, path, params=None):
"""Walk FMC pagination. expanded=true so objects arrive whole."""
out, offset, limit = [], 0, 1000
while True:
p = dict(params or {})
p.update({"offset": offset, "limit": limit, "expanded": "true"})
data = self.get(path, p)
if data is None:
return out
items = data.get("items") or []
out.extend(items)
paging = data.get("paging") or {}
count = paging.get("count", len(out))
offset += limit
if offset >= count or not items:
break
if self.save_dir:
self._save(path, params, out)
return out
def _save(self, path, params, items):
name = re.sub(r"[^A-Za-z0-9]+", "_", path).strip("_")
if params:
name += "__" + re.sub(r"[^A-Za-z0-9]+", "_", json.dumps(params, sort_keys=True))
with open(os.path.join(self.save_dir, name[:180] + ".json"), "w",
encoding="utf-8") as fh:
json.dump({"path": path, "params": params, "items": items}, fh, indent=1)
class Offline:
"""Replay a directory produced by --save-dir. No network, no credentials.
This exists so the logic can be tested and reviewed without an FMC, and
so somebody can collect once and analyse repeatedly.
"""
def __init__(self, directory):
self.store = {}
self.calls = 0
if not os.path.isdir(directory):
raise SystemExit("ERROR: not a directory: %s" % directory)
for f in sorted(os.listdir(directory)):
if not f.endswith(".json"):
continue
with open(os.path.join(directory, f), encoding="utf-8") as fh:
try:
blob = json.load(fh)
except ValueError as exc:
raise SystemExit("ERROR: %s is not valid JSON: %s" % (f, exc))
key = (blob.get("path"), json.dumps(blob.get("params") or {}, sort_keys=True))
self.store[key] = blob.get("items") or []
if not self.store:
raise SystemExit("ERROR: no saved responses found in %s" % directory)
self.default_domain = None
def get(self, path, params=None):
return {"items": self.get_all(path, params)}
def get_all(self, path, params=None):
self.calls += 1
return self.store.get((path, json.dumps(params or {}, sort_keys=True)), [])
class Obj:
__slots__ = ("name", "kind", "uuid", "used_by", "native_unused", "transitively_dead")
def __init__(self, name, kind, uuid):
self.name = name
self.kind = kind
self.uuid = uuid
self.used_by = []
self.native_unused = None # True / False / None = not applicable
self.transitively_dead = False
def walk_refs(node, out, where):
"""Collect every object name/id a rule field names, at any nesting depth."""
if isinstance(node, dict):
if "name" in node and ("id" in node or "type" in node):
out.append((node.get("name"), node.get("id"), where))
for v in node.values():
walk_refs(v, out, where)
elif isinstance(node, list):
for v in node:
walk_refs(v, out, where)
def collect(api, domain_uuid, use_native):
base = "/api/fmc_config/v1/domain/%s" % domain_uuid
objs = {}
for path, kind, native_covered in OBJECT_CLASSES:
items = api.get_all(base + "/object/" + path)
for it in items:
# Skip FMC's own read-only system objects: they cannot be deleted
# and listing them as unused is noise.
if it.get("overridable") is None and it.get("metadata", {}).get("readOnly"):
continue
name, uid = it.get("name"), it.get("id")
if not name:
continue
o = Obj(name, kind, uid)
# Only claim a class is covered when the filter is actually being
# consulted. Marking it covered without asking made those objects
# fall into a section that is not printed in --no-native mode, so
# they vanished from the report entirely.
if native_covered and use_native:
o.native_unused = False
objs[uid or (kind, name)] = o
# Nested group membership - the class home-grown scripts miss.
for it in items:
for member in (it.get("objects") or []):
refs = [(member.get("name"), member.get("id"),
"%s '%s'" % (kind, it.get("name")))]
for nm, uid2, where in refs:
key = uid2 or (kind, nm)
if key in objs:
objs[key].used_by.append(where)
# The native filter, used rather than reimplemented.
if use_native:
for path, kind, native_covered in OBJECT_CLASSES:
if not native_covered:
continue
items = api.get_all(base + "/object/" + path,
{"filter": "unusedOnly:true"})
flagged = {it.get("id") for it in items}
for key, o in objs.items():
if o.kind == kind and o.uuid in flagged:
o.native_unused = True
return objs
def collect_policy_refs(api, domain_uuid, objs):
base = "/api/fmc_config/v1/domain/%s" % domain_uuid
by_uuid = {o.uuid: o for o in objs.values() if o.uuid}
by_name = {}
for o in objs.values():
by_name.setdefault(o.name, []).append(o)
def record(name, uid, where):
if uid and uid in by_uuid:
by_uuid[uid].used_by.append(where)
elif name:
for o in by_name.get(name, []):
o.used_by.append(where)
scanned = []
for pol in api.get_all(base + "/policy/accesspolicies"):
pid, pname = pol.get("id"), pol.get("name")
rules = api.get_all(base + "/policy/accesspolicies/%s/accessrules" % pid)
scanned.append("access policy '%s' (%d rules)" % (pname, len(rules)))
for rule in rules:
where = "access rule '%s' in '%s'" % (rule.get("name"), pname)
found = []
for field in RULE_REF_FIELDS:
if field in rule:
walk_refs(rule[field], found, where)
for nm, uid, w in found:
record(nm, uid, w)
for pol in api.get_all(base + "/policy/prefilterpolicies"):
pid, pname = pol.get("id"), pol.get("name")
rules = api.get_all(base + "/policy/prefilterpolicies/%s/prefilterrules" % pid)
scanned.append("prefilter policy '%s' (%d rules)" % (pname, len(rules)))
for rule in rules:
where = "prefilter rule '%s' in '%s'" % (rule.get("name"), pname)
found = []
for field in RULE_REF_FIELDS:
if field in rule:
walk_refs(rule[field], found, where)
for nm, uid, w in found:
record(nm, uid, w)
for pol in api.get_all(base + "/policy/ftdnatpolicies"):
pid, pname = pol.get("id"), pol.get("name")
for sub, label in (("autonatrules", "auto NAT"), ("manualnatrules", "manual NAT")):
rules = api.get_all(base + "/policy/ftdnatpolicies/%s/%s" % (pid, sub))
if rules:
scanned.append("%s in '%s' (%d rules)" % (label, pname, len(rules)))
for rule in rules:
where = "%s rule in '%s'" % (label, pname)
found = []
for field in RULE_REF_FIELDS:
if field in rule:
walk_refs(rule[field], found, where)
for nm, uid, w in found:
record(nm, uid, w)
return scanned
def mark_transitive(objs):
by_name = {}
for o in objs.values():
by_name.setdefault(o.name, []).append(o)
def container_of(where):
if "group '" in where:
return where.split("group '", 1)[1].rstrip("'")
return None
for _ in range(8):
changed = False
for o in objs.values():
if not o.used_by or o.transitively_dead:
continue
holders = [container_of(w) for w in o.used_by]
if not all(holders):
continue
alive = any(c.used_by and not c.transitively_dead
for h in holders for c in by_name.get(h, []))
if not alive:
o.transitively_dead = True
changed = True
if not changed:
break
CSV_HEADER = ["object_type", "name", "uuid", "verdict", "safe_to_delete",
"fmc_native_filter", "referenced_by", "note"]
def write_csv(path, rows):
"""One row per object, with the verdict as a column.
CSV rather than xlsx on purpose: the standard library writes it, Excel
and Sheets both open it, and a cleanup project lives in a spreadsheet
where someone filters by verdict and assigns an owner per row.
"""
try:
with open(path, "w", encoding="utf-8-sig", newline="") as fh:
w = csv.writer(fh)
w.writerow(CSV_HEADER)
w.writerows(rows)
except OSError as exc:
print("WARNING: could not write %s: %s" % (path, exc))
return
print("CSV written: %s (%d rows)" % (path, len(rows)))
def native_col(o):
if o.native_unused is True:
return "says unused"
if o.native_unused is False:
return "did not flag"
return "not covered"
def report(objs, scanned, use_native, limit, show_used, csv_path=None):
unused = [o for o in objs.values() if not o.used_by or o.transitively_dead]
# The dangerous disagreement: FMC's own filter says unused, but this tool
# found a reference. Somebody acting on the GUI list would delete it.
contested = [o for o in objs.values()
if o.native_unused is True and o.used_by and not o.transitively_dead]
confirmed, native_only, script_only, transitive = [], [], [], []
for o in unused:
if o.transitively_dead:
transitive.append(o)
elif o.native_unused is True:
confirmed.append(o) # FMC agrees and so do we
elif o.native_unused is False:
script_only.append(o) # we say unused, FMC's filter did not
else:
native_only.append(o) # no native filter for this class
print("=" * 78)
print("fmc-unused-objects")
print("=" * 78)
print("Objects inventoried: %d" % len(objs))
print()
print("Policies scanned:")
for s in scanned or [" (none found)"]:
print(" - %s" % s)
print()
def block(title, items, note=None):
if not items:
return 0
print("-" * 78)
print(title)
if note:
for line in note:
print(" %s" % line)
print("-" * 78)
for o in sorted(items, key=lambda x: (x.kind, x.name))[:limit]:
print(" %-16s %s" % (o.kind, o.name))
if len(items) > limit:
print(" ... and %d more (raise --limit)" % (len(items) - limit))
print()
return len(items)
native_header = ("UNUSED - object classes FMC's filter does not cover"
if use_native else
"UNUSED - no reference found (native filter not consulted)")
native_note = (["Groups, ports, URLs, zones and VLAN tags. The native filter is",
"documented for network addresses only, so for these there is no",
"second opinion to compare against."]
if use_native else
["--no-native was passed, so there is no cross-check at all.",
"Every result here rests on this tool's reference walk alone."])
total = 0
if contested:
print("!" * 78)
print("DO NOT DELETE - FMC's filter calls these unused, but this tool")
print("found a reference to each one.")
print("!" * 78)
print(" Anyone acting on the GUI's Show Unused Objects list would remove")
print(" these. Confirm by hand before touching them, and treat it as a")
print(" reason to distrust the list rather than this tool.")
for o in sorted(contested, key=lambda x: (x.kind, x.name))[:limit]:
print(" %-16s %-30s referenced by: %s" % (o.kind, o.name, o.used_by[0]))
print()
if use_native:
total += block(
"UNUSED - confirmed by BOTH this tool and FMC's own unusedOnly filter",
confirmed,
["The strongest result available. Two independent methods agree."])
total += block(
"UNUSED - found by this tool, NOT flagged by FMC's filter",
script_only,
["Investigate these first. Either the filter scopes differently, or",
"something references it that this tool did not read. Treat the",
"disagreement as a question, not as a finding."])
total += block(native_header, native_only, native_note)
total += block(
"UNUSED TRANSITIVELY - referenced only by groups that are themselves unused",
transitive,
["Kept alive by a corpse. Delete the container first, then re-run."])
if show_used:
used = [o for o in objs.values() if o.used_by and not o.transitively_dead]
print("-" * 78)
print("IN USE (%d)" % len(used))
print("-" * 78)
for o in sorted(used, key=lambda x: (x.kind, x.name))[:limit]:
extra = "" if len(o.used_by) == 1 else " (+%d more)" % (len(o.used_by) - 1)
print(" %-16s %-34s %s%s" % (o.kind, o.name, o.used_by[0], extra))
print()
print("=" * 78)
print("WHAT THIS REPORT DID NOT CHECK")
print("=" * 78)
for line in NOT_CHECKED:
print(" - %s" % line)
print()
print(" An object listed as unused is a CANDIDATE TO INVESTIGATE, not a")
print(" delete list. This tool never deletes anything.")
print()
if csv_path:
rows = []
for o in contested:
rows.append([o.kind, o.name, o.uuid or "", "DO_NOT_DELETE", "NO",
native_col(o), o.used_by[0],
"FMC's filter calls this unused but a policy "
"references it - resolve before touching it"])
for o in confirmed:
rows.append([o.kind, o.name, o.uuid or "", "UNUSED", "candidate",
native_col(o), "",
"both this tool and FMC's filter agree"])
for o in script_only:
rows.append([o.kind, o.name, o.uuid or "", "UNUSED", "candidate",
native_col(o), "",
"this tool found no reference; FMC's filter did not "
"flag it - investigate the disagreement"])
for o in native_only:
rows.append([o.kind, o.name, o.uuid or "", "UNUSED", "candidate",
native_col(o), "",
"no native second opinion exists for this class"])
for o in transitive:
rows.append([o.kind, o.name, o.uuid or "", "UNUSED_TRANSITIVE",
"candidate", native_col(o),
o.used_by[0] if o.used_by else "",
"only referenced by a group that is itself unused"])
for o in objs.values():
if o.used_by and not o.transitively_dead and o not in contested:
rows.append([o.kind, o.name, o.uuid or "", "IN_USE", "no",
native_col(o), o.used_by[0]
+ ("" if len(o.used_by) == 1
else " (+%d more)" % (len(o.used_by) - 1)), ""])
write_csv(csv_path, rows)
print("Total candidates: %d" % total)
return total
def main():
ap = argparse.ArgumentParser(
description="Find objects in Cisco Secure Firewall Management Center "
"that no policy references. Read-only; never deletes.")
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--host", help="FMC hostname or IP")
src.add_argument("--offline-dir",
help="Replay a directory written by --save-dir. No network "
"and no credentials at all.")
ap.add_argument("--domain", help="Domain name or UUID (default: the account's)")
ap.add_argument("--insecure", action="store_true",
help="Skip TLS verification (known self-signed certificate only)")
ap.add_argument("--no-native", action="store_true",
help="Do not use FMC's own unusedOnly filter, so you lose "
"the cross-check")
ap.add_argument("--show-used", action="store_true",
help="Also list the objects that ARE referenced, and by what")
ap.add_argument("--limit", type=int, default=50, help="Max per section (default 50)")
ap.add_argument("--csv", metavar="PATH",
help="Also write every object and its verdict to a CSV, "
"for the spreadsheet the cleanup actually lives in")
ap.add_argument("--save-dir", help="Write every API response here for offline re-runs")
args = ap.parse_args()
use_native = not args.no_native
if args.offline_dir:
api = Offline(args.offline_dir)
domain_uuid = args.domain or "OFFLINE"
else:
user = os.environ.get("FMC_USER")
password = os.environ.get("FMC_PASS")
if not user or not password:
raise SystemExit(
"ERROR: FMC_USER and FMC_PASS must be set in the environment.\n"
"\n"
" Linux / macOS: export FMC_USER=api_reader\n"
" export FMC_PASS='...'\n"
" PowerShell: $env:FMC_USER='api_reader'\n"
" $env:FMC_PASS='...'\n"
"\n"
"Read from the environment on purpose: an argument is visible in\n"
"`ps auxww` and lands in shell history.\n"
"\n"
"The account needs REST access. A read-only role is enough.")
try:
import requests
except ImportError:
raise SystemExit("ERROR: pip install requests")
if args.save_dir:
os.makedirs(args.save_dir, exist_ok=True)
try:
api = FMC(args.host, user, password, not args.insecure, args.save_dir)
except requests.exceptions.SSLError as exc:
raise SystemExit(
"ERROR: TLS verification failed for %s: %s\n"
"Use --insecure only for a certificate you recognise." % (args.host, exc))
except requests.exceptions.RequestException as exc:
raise SystemExit("ERROR: could not reach %s: %s" % (args.host, exc))
domain_uuid = args.domain or api.default_domain
if not domain_uuid:
domains = (api.get("/api/fmc_platform/v1/info/domain") or {}).get("items", [])
if not domains:
raise SystemExit("ERROR: could not determine the domain UUID.")
domain_uuid = domains[0]["uuid"]
if args.domain and not re.match(r"^[0-9a-fA-F-]{36}$", args.domain):
domains = (api.get("/api/fmc_platform/v1/info/domain") or {}).get("items", [])
match = [d for d in domains if d.get("name") == args.domain]
if not match:
raise SystemExit(
"ERROR: no domain named %s. Available: %s"
% (args.domain, ", ".join(d.get("name", "?") for d in domains)))
domain_uuid = match[0]["uuid"]
objs = collect(api, domain_uuid, use_native)
if not objs:
print("No objects found. Check the domain and that the account's role")
print("allows reading object management.")
return 2
scanned = collect_policy_refs(api, domain_uuid, objs)
mark_transitive(objs)
n = report(objs, scanned, use_native, args.limit, args.show_used, args.csv)
print("API calls made: %d" % api.calls)
return 1 if n else 0
if __name__ == "__main__":
sys.exit(main())
--hosttextFMC hostname or IP. Needs FMC_USER and FMC_PASS in the environment. Mutually exclusive with --offline-dir
--offline-dirtextReplay a directory written by --save-dir. No network and no credentials at all
--domaintextDomain name or UUID to analyse. A name is resolved to its UUID. Defaults to the account's domain
--insecureflagSkip TLS verification. Only for a certificate you recognise
--no-nativeflagDo not consult FMC's unusedOnly filter. You lose the cross-check and every result rests on this tool alone
--show-usedflagAlso list objects that ARE referenced and by what, to prove a specific object is in use
--limitnumberMaximum objects listed per section. Default 50
--csvtextAlso write every object and its verdict to a CSV, including what FMC's own filter said about each one. Opens in Excel
--save-dirtextWrite every API response here so later runs can work offline with no API calls
What This Solves
An FMC that has been running for years holds thousands of network objects, ports, URLs and groups. Nobody knows which ones still matter, and cleaning them up is a real, recurring job. FMC is not empty-handed here, and this tool is built around that rather than ignoring it. FMC has: - A Show Unused Objects view in the GUI, which CANNOT BE EXPORTED. - An API filter, unusedOnly:true, documented for NETWORK ADDRESSES ONLY. So this tool does three things FMC does not: 1. Exports. You get a report you can send to someone, diff next month, or attach to a change request. 2. Covers the classes the filter does not: groups, ports, URLs, security zones, VLAN tags. 3. CROSS-CHECKS. It walks the policies itself and compares its answer with FMC's filter. When the two disagree, it says so instead of picking one. That third one matters more than it sounds. The most dangerous output this tool produces is the one where FMC says an object is unused and the tool found a reference to it - because somebody working from the GUI list would delete it.
Is It Safe to Run
Yes. It is read-only and it never deletes anything. - GET requests only. No POST, PUT or DELETE except the one POST that generates the auth token. - It respects FMC's rate limit of 120 GET requests per minute per IP by throttling itself to under two per second. You can run it during the day. - With --offline-dir it reads saved JSON responses and makes no network connection at all. A read-only FMC role is enough.
Before You Start
- Python 3.8 or newer - pip install requests (not needed for --offline-dir) - An FMC account with REST API access. Read-only is enough. - Network access to the FMC on TCP 443 export FMC_USER=api_reader # Linux / macOS export FMC_PASS='...' $env:FMC_USER='api_reader' # PowerShell $env:FMC_PASS='...' Read from the environment on purpose: an argument is visible in ps auxww while the process runs and lands in shell history. Note on tokens: FMC allows a limited number of active API tokens per user. If you get a 429 at login, previous tokens have not expired yet - wait, or use a dedicated API account rather than a shared one.
Your First Run
1. Set the credentials as above. 2. Collect and analyse in one go, saving the responses: python3 fmc_unused_objects.py --host fmc.example.com \ --save-dir ./fmc-snapshot 3. Re-run the analysis as many times as you like with no further API calls: python3 fmc_unused_objects.py --offline-dir ./fmc-snapshot 4. Get it into a spreadsheet, which is where a cleanup actually happens: python3 fmc_unused_objects.py --offline-dir ./fmc-snapshot --csv unused.csv Step 3 is the one to get into the habit of. Collect once, analyse repeatedly, and send the snapshot to a colleague who has no FMC access.
What the Output Means
============================================================== fmc-unused-objects ============================================================== Objects inventoried: 1482 Policies scanned: - access policy 'ACP-Corp' (914 rules) - prefilter policy 'PF-Edge' (12 rules) - auto NAT in 'NAT-Edge' (61 rules) !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! DO NOT DELETE - FMC's filter calls these unused, but this tool found a reference to each one. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! host SRV-WEB-01 referenced by: access rule 'allow-web' in 'ACP-Corp' The report has up to five sections, and they are five different claims. Do NOT delete FMC's own filter lists the object as unused, and this tool found a policy referencing it. One of the two is wrong, and until you know which, the object must not be deleted. This is the single most valuable thing the tool prints, because the GUI list would have you remove it. UNUSED - confirmed by BOTH This tool found no reference AND FMC's filter agrees. Two independent methods, same answer. The strongest result available, and still a candidate rather than a decision. UNUSED - found by this tool, NOT flagged by FMC's filter Investigate these first among the unused. Either the filter scopes differently than you assume, or something references the object that this tool does not read. Treat a disagreement as a question. UNUSED - object classes FMC's filter does not cover Groups, ports, URLs, zones, VLAN tags. No native second opinion exists for these, so the result rests on this tool's reference walk alone. Unused transitively The object IS referenced - but only by a group that is itself unreferenced. It is being kept alive by a corpse. Delete the container first, then re-run: the member becomes a plain unused. Most home-grown scripts miss this and report the member as in use forever. What IS scanned: - Access policies and all their rules - Prefilter policies and rules - FTD NAT policies: auto NAT and manual NAT rules - Nested group membership, to any depth - Source and destination networks, ports, zones, URLs, VLAN tags, and the original and translated fields on NAT rules What is NOT, which the report prints every time: - FlexConfig objects and policies - Remote access and site-to-site VPN topologies - Platform settings, health policies, variable sets - Identity, SSL and DNS policies - A device's own local configuration outside FMC That list is the point. A cleanup tool that does not say where it did not look is not safe to act on. The csv --csv writes one row per object, with the verdict as a column, so the report stops being something you read and becomes something you work from. It opens directly in Excel, LibreOffice and Sheets. object_type,name,uuid,verdict,safe_to_delete,fmc_native_filter,refere... host,SRV-WEB-01,h1,DO_NOT_DELETE,NO,says unused,access rule 'allow-web'... host,NATIVE-SAYS-UNUSED,h5,UNUSED,candidate,says unused,,both this tool... host,TRULY-ORPHAN,h4,UNUSED,candidate,did not flag,,this tool found no... port,TCP-9999,p2,UNUSED,candidate,not covered,,no native second opinion... The fmc_native_filter column is the one worth sorting on. It carries what FMC's own filter said about each object - says unused, did not flag, or not covered - next to this tool's verdict. The disagreement is visible in the spreadsheet rather than buried in a terminal. verdict DO_NOT_DELETE, UNUSED, UNUSED_TRANSITIVE, IN_USE safe_to_delete NO, candidate, or no. Never yes - this tool does not grant permission, it narrows the list fmc_native_filter what FMC's unusedOnly filter said referenced_by the first reference found, with a count of the rest Every object appears, including the ones in use, so that when somebody asks 'what about this one', the answer is in the same file.
Exit Codes for Cron and CI
0 No candidates found. 1 At least one candidate, including the DO NOT DELETE case. 2 Could not reach or read FMC, or no objects were returned. Exit 1 is informational - suitable for a scheduled report. Do not wire this to anything that deletes.
Every Flag
Arguments --host HOST FMC hostname or IP. Needs FMC_USER and FMC_PASS. --offline-dir DIR Replay a directory written by --save-dir. No network and no credentials. Mutually exclusive with --host. --domain NAME|UUID Domain to analyse. Defaults to the account's domain. A name is resolved to its UUID. --insecure Skip TLS verification. Known certificate only. --no-native Do not consult FMC's unusedOnly filter. You lose the cross-check and every result rests on this tool alone. --show-used Also list the objects that ARE referenced, and by what. Use it to prove a specific object is in use. --limit N Maximum objects listed per section. Default 50. --csv PATH Also write every object and its verdict to a CSV. --save-dir DIR Write every API response here for offline re-runs. Credentials - environment only FMC_USER FMC username with REST API access FMC_PASS password Object classes inventoried networks, hosts, ranges, fqdns, networkgroups, protocolportobjects, icmpv4objects, portobjectgroups, urls, urlgroups, vlantags, vlangrouptags, securityzones Rate limiting FMC accepts 120 GET requests per minute from one IP. The tool throttles itself below that and prints the number of calls it made at the end. A large estate takes minutes rather than seconds, on purpose.
When Something Fails
ERROR: FMC_USER and FMC_PASS must be set in the environment Exported in another shell, or in one that has closed. Export them again. ERROR: <host> rejected the credentials (401) Wrong password, or the account has no REST API access. Confirm with: curl -kX POST https://FMC/api/fmc_platform/v1/auth/generatetoken \ -u USER:PASS -i ERROR: too many active tokens for this user FMC limits concurrent API tokens. Wait for the old ones to expire, or use a dedicated API account instead of a shared one. ERROR: TLS verification failed The FMC certificate is self-signed. Use --insecure, and only for a certificate you recognise. The run is slow That is the rate limiter. 120 GET per minute is FMC's limit, not a choice. Use --save-dir once and then --offline-dir for repeat analysis. An object you know is in use appears as unused Check the NOT CHECKED list first: FlexConfig and VPN topologies are the usual answer. If it is none of those, a reference class is missing from the tool, and that is worth reporting - a missed reference class is the one defect in this kind of tool that causes real damage. Everything appears unused The account's role can read objects but not policies, so the reference walk found nothing. The Policies scanned list at the top of the report will be empty or very short - check it before believing any result. The DO NOT DELETE section is large Either the domain is wrong, or the role cannot read some policies, or FMC's filter is scoped differently than expected. Do not act on any part of the report until that is understood.