Classify an Ivanti Connect Secure inventory before emergency recovery

John Burns

An emergency advisory creates pressure to ask the wrong first question. It is easy to start with “which appliance should be upgraded?” before establishing which appliances are actually in scope, which release each one runs, and which version strings cannot safely be interpreted by an unattended check.

CISA added CVE-2026-104935, an Ivanti Connect Secure authentication-bypass vulnerability, to its Known Exploited Vulnerabilities catalog on 13 October 2026. CISA says the issue is being exploited and directs affected organizations to Ivanti’s update and recovery guidance. Ivanti lists affected release lines and resolved releases, but the advisory is not a substitute for an asset inventory, a change record, or a compromise assessment. In particular, CISA notes that the Integrity Checker Tool may not find evidence of this issue because the actor commands execute in memory.

The useful first gate is a local, fail-closed classifier. It consumes a deliberately small, sanitized inventory export and labels only the release strings that the advisory makes unambiguous. An unknown release string stays REVIEW_VERSION; it is not called safe. That gives the incident owner a short, reviewable list before any appliance is contacted, upgraded, reset, rebuilt, or restored.

The validation described here used Python 3.14.7 on Linux amd64 and synthetic asset labels only. Eight sample rows covered affected, resolved, explicitly excluded, and malformed release strings. The classifier marked 25.1.1.1, 25.1.2.2, and 22.8R2.4 as affected; marked 25.1.1.2 and 22.8R2.5 as resolved; retained 22.7R2.14 as not affected by this advisory; and sent an abbreviated 25.1.3 value to manual review. A CSV with the wrong header exited nonzero. The important result is the negative one: a partial version string did not become an automated clearance decision.

Keep the inventory separate from the recovery action

Do not put an appliance address, administrator account, serial number, configuration export, session data, or authentication material in the working CSV. This check does not need it. Use an internal asset identifier that the incident team can map back to the approved inventory system, then retain the real mapping in that system rather than in a ticket attachment or source repository.

The expected input has exactly two columns:

asset,release
edge-a,25.1.1.1
edge-b,25.1.1.2
edge-c,22.8R2.4

Export or transcribe only release information that has been independently checked through the organization’s approved management path. Do not discover versions by probing public-facing appliances from an ad hoc workstation. That can create confusing access logs during an incident and says little about appliances that are not externally reachable. Inventory evidence, load-balancer records, virtualization records, and the vendor management interface are better sources when they are available.

The asset field is an opaque label. The classifier treats it as an identifier, not as a host to contact. The release field is a version value copied exactly from the inventory. Keep the original export with the incident record under the access controls that apply to it; use a redacted copy for this local decision aid.

Encode only the vendor’s stated boundaries

Ivanti’s advisory says that affected releases include 25.1.1.1 and earlier, 25.1.2.2 and earlier, and 22.8R2.4 and earlier. It lists 25.1.1.2, 25.1.3.0 and 25.1.3.1, and 22.8R2.5 as resolved versions. The same advisory says that 22.7R2.14 and earlier are not affected by CVE-2026-104935.

Those statements are not a general-purpose version ordering rule. Product release names contain branch and platform meaning that a simple tuple comparison can lose. The classifier below deliberately supports only the release patterns needed for this advisory. It treats every other value as a review task. That is less clever than attempting to normalize every possible vendor release string, but it preserves the safer failure mode during a fast-moving response.

Save this as check_versions.py in a private incident workspace, not a public repository:

#!/usr/bin/env python3
import csv
import re
import sys
from pathlib import Path

PATTERNS = {
    "22.8": (re.compile(r"^22\.8R2\.(\d+)$"), 5),
    "25.1.1": (re.compile(r"^25\.1\.1\.(\d+)$"), 2),
    "25.1.2": (re.compile(r"^25\.1\.2\.(\d+)$"), 3),
}


def classify(version):
    if re.fullmatch(r"22\.7R2\.\d+", version):
        return "NOT_AFFECTED_BY_ADVISORY"
    for _, (pattern, first_fixed) in PATTERNS.items():
        match = pattern.fullmatch(version)
        if match:
            return "AFFECTED" if int(match.group(1)) < first_fixed else "RESOLVED"
    return "REVIEW_VERSION"


with Path(sys.argv[1]).open(newline="", encoding="utf-8") as handle:
    rows = csv.DictReader(handle)
    if rows.fieldnames != ["asset", "release"]:
        raise SystemExit("expected CSV header: asset,release")
    for row in rows:
        asset = row["asset"].strip()
        release = row["release"].strip()
        if not asset or not release:
            raise SystemExit("asset and release must both be non-empty")
        print(f"{asset}\t{release}\t{classify(release)}")

The regular expressions require the entire release value to match. That prevents 25.1.3, a string with a suffix, or a value copied with unexpected punctuation from being silently treated as a known release. The numeric patch comparison only happens after the code has identified a recognized branch.

The special 22.7 rule is intentionally narrow. It represents the vendor’s statement about this CVE, not a support or lifecycle decision. Ivanti says the 22.7 line reaches end of life on 31 January 2027 and recommends transition planning. An inventory result of NOT_AFFECTED_BY_ADVISORY means only that this particular vulnerability is not in scope according to the cited advisory; it does not mean the appliance needs no maintenance.

Make unresolved rows visible

Run the check against a reviewed copy of the inventory:

python3 check_versions.py inventory.csv | tee classification.tsv

A representative result is:

edge-a  25.1.1.1  AFFECTED
edge-b  25.1.1.2  RESOLVED
edge-c  25.1.2.2  AFFECTED
edge-d  25.1.3.0  REVIEW_VERSION
edge-e  22.8R2.4  AFFECTED
edge-f  22.8R2.5  RESOLVED
edge-g  22.7R2.14 NOT_AFFECTED_BY_ADVISORY
edge-h  25.1.3    REVIEW_VERSION

The 25.1.3.0 result deserves explanation. Ivanti lists it as a resolved release, but this intentionally small classifier does not accept that branch because its rules only encode the affected branches and their immediate fixed boundaries. Add a specific rule only after the incident owner checks the current vendor advisory and records the reason. Until then, REVIEW_VERSION is the correct operational outcome. A tool that produces a shorter “safe” list by guessing is less useful than one that preserves ambiguity.

Use the labels to drive different work queues:

  • AFFECTED requires the vendor-directed emergency response path and a decision on whether compromise assessment and recovery are necessary.
  • RESOLVED still needs evidence that the inventory value is current and that the appliance was not exposed before the update.
  • NOT_AFFECTED_BY_ADVISORY remains in normal vulnerability and lifecycle management.
  • REVIEW_VERSION requires a human to reconcile the exact product, branch, platform, and advisory revision before any automated conclusion.

Do not treat a resolved release as proof of no compromise. CISA’s recovery guidance for suspected exploitation includes backing up configuration, factory-resetting and upgrading a physical appliance or rebuilding a virtual or cloud system, restoring configuration, and rotating credentials, certificates, API keys, and license-server credentials. Those are high-impact steps. Follow the current vendor procedure and the organization’s incident-response authority rather than placing reset commands in a generic script.

Test the refusal path before relying on it

A decision tool needs a negative test. The following malformed header must fail rather than parsing a column named version as if it were release:

asset,version
edge-a,25.1.1.1

Run the check and preserve its exit status:

python3 check_versions.py bad_header.csv

The validation run returned exit status 1 and printed:

expected CSV header: asset,release

That strictness is intentional. A header mismatch can be caused by an export change, a spreadsheet edit, or a human copy-and-paste error. Continuing after it would make the output look authoritative while evaluating the wrong input. Extend the script only with fixtures for every newly supported release format, including a test that proves unrecognized values still require review.

The finished inventory gate does not patch an appliance and it does not determine whether one was compromised. It provides a small, tested boundary before those decisions: only vendor-recognized version strings receive an automatic classification, while every ambiguous row remains visible to an incident owner.

Sources