Skip to content

Instantly share code, notes, and snippets.

@shino
Last active August 4, 2026 02:57
Show Gist options
  • Select an option

  • Save shino/5cefdef6d36283f50795e32ffcf1a51d to your computer and use it in GitHub Desktop.

Select an option

Save shino/5cefdef6d36283f50795e32ffcf1a51d to your computer and use it in GitHub Desktop.
fortinet-cvrf supplement generator (vuls-data-update#892): handmade + Fortinet CNA -> supplement_data.go

fortinet-cvrf supplement generator

Generates the supplement_data.go table embedded in vuls-data-update/pkg/extract/fortinet/cvrf/ (MaineK00n/vuls-data-update#892): affected-product data for the historical Fortinet CVRF advisories (2012-2022) whose product_statuses/product_tree are empty upstream.

Sources: the legacy curated handmade dataset (vulsio/vuls-data-raw-fortinet) and Fortinet's CNA records in cvelistV5 (containers.cna.affected, assignerShortName: fortinet only). Per advisory the sources merge at product level (handmade wins per product, CNA fills missing products); every version-level disagreement between the two is reported and was arbitrated by hand against the advisory's Affected Products note — the outcomes live in MANUAL_OVERRIDES / HANDMADE_EXCLUDED / PRODUCT_ROW_FIXUPS in generate.py, each with its justification.

Usage

./fetch_inputs.sh            # clone handmade + oras-pull cvrf/csaf dotgits into work/
./generate.py --fetch-cves   # download cvelistV5 records for the gap CVEs
./generate.py                # writes supplement_data.go, supplement.json, report.md
cp supplement_data.go <vuls-data-update>/pkg/extract/fortinet/cvrf/

generate.py parses the product table (pkg/extract/fortinet/internal/product/table.go) out of a vuls-data-update checkout expected at ../../vuls-data-update relative to the script; adjust TABLE_GO if the layout differs. report.md accounts for every statuses-empty advisory (supplemented / reviewed content-only / unreviewed must be zero) and lists all cross-source diffs.

#!/usr/bin/env bash
# Fetch the inputs for generating the fortinet-cvrf supplement table:
# work/vuls-data-raw-fortinet — legacy handmade dataset (git clone)
# work/vuls-data-raw-fortinet-cvrf — CVRF raw dotgit (oras pull + checkout)
# work/vuls-data-raw-fortinet-csaf — CSAF raw dotgit (oras pull + checkout)
# work/cvelist/CVE-*.json — cvelistV5 records for the gap CVEs
#
# cvelist fetching happens in generate.py's --fetch-cves step because the CVE
# list is derived from the gap computation.
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p work
cd work
if [ ! -d vuls-data-raw-fortinet ]; then
git clone --depth 1 https://github.com/vulsio/vuls-data-raw-fortinet.git
fi
for t in vuls-data-raw-fortinet-cvrf vuls-data-raw-fortinet-csaf; do
if [ ! -d "$t" ]; then
oras pull "ghcr.io/vulsio/vuls-data-db:$t" --output "$t-dl"
mkdir -p "$t-unpack"
tar --zstd -xf "$t-dl/$t.tar.zst" -C "$t-unpack"
mv "$t-unpack/$t" "$t"
rmdir "$t-unpack"
rm -rf "$t-dl"
git -C "$t" config core.bare false
git -C "$t" checkout -f HEAD
fi
done
echo "inputs ready under $(pwd)"
#!/usr/bin/env python3
"""Generate the fortinet-cvrf supplement table.
Old Fortinet CVRF advisories (2012-2021 and a few 2022) carry no
product_statuses/product_tree, so `vuls-data-update extract fortinet-cvrf`
emits them content-only. This script derives the missing affected-product data
for those advisories from two sources and emits a static supplement table to be
embedded in vuls-data-update:
1. the legacy curated handmade dataset (vuls-data-raw-fortinet) — good
version ranges for ~2012-2018, but 2019+ entries are mostly empty and
sometimes list not-impacted products, so only nodes with an actual
version constraint are used;
2. Fortinet's own CNA records in cvelistV5 (containers.cna.affected) —
covers ~2016-2022; only CVEs assigned by the "fortinet" CNA are used
(other assigners describe the upstream product, not Fortinet's).
Per advisory, handmade wins when it has constrained nodes; CNA fills the rest.
Advisories where both exist are cross-checked and diffs land in report.md for
human review.
Usage:
./fetch_inputs.sh
./generate.py --fetch-cves # downloads cvelistV5 records for the gap CVEs
./generate.py # writes supplement_data.go (for the cvrf package),
# supplement.json (tooling byproduct), report.md
The output supplement.json maps advisory ID -> product rows:
{"FG-IR-012-001": [{"product": "FortiOS", "ranges": [{"ge": "4.3.0", "le": "4.3.5"}]}]}
where "product" is a key of vuls-data-update's fortinet/internal/product table
(this script parses table.go and hard-fails on any name/CPE it cannot map).
"""
import argparse
import collections
import glob
import json
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
WORK = os.path.join(HERE, "work")
HM = os.path.join(WORK, "vuls-data-raw-fortinet")
CVRF = os.path.join(WORK, "vuls-data-raw-fortinet-cvrf")
CVELIST = os.path.join(WORK, "cvelist")
TABLE_GO = os.path.normpath(os.path.join(
HERE, "../../vuls-data-update/pkg/extract/fortinet/internal/product/table.go"))
CVE_RE = re.compile(r"CVE-(?:<br />)?(\d{4})-(\d+)")
VER_RE = re.compile(r"^\d+(\.\d+){0,3}$")
# --- product table ----------------------------------------------------------
def load_table():
"""Parse table.go: name -> (cpe, twoComponentVersions)."""
src = open(TABLE_GO).read()
entries = {}
for m in re.finditer(
r'"([^"]+)":\s*\{cpe: "([^"]+)"(.*?)\},\n', src):
entries[m.group(1)] = (m.group(2), "twoComponentVersions: true" in m.group(3))
if not entries:
sys.exit(f"could not parse any entries from {TABLE_GO}")
return entries
def reverse_table(entries):
"""(vendor, product) -> canonical product name. Handmade CPEs vary in part
(a: vs o:) and carry target_sw/edition qualifiers the curated table CPEs do
not, so match on vendor:product only. For vendor:product pairs shared by
several names any name resolves to the same table CPE; the plain
OS-agnostic-looking one is preferred via the explicit map."""
prefer = {
("fortinet", "forticlient"): "FortiClientWindows",
}
rev = {}
for name, (cpe, _) in sorted(entries.items()):
parts = cpe.split(":")
rev.setdefault((parts[3], parts[4]), name)
rev.update(prefer)
return rev
# Handmade FortiClient nodes carry the platform in target_sw; keep it.
FORTICLIENT_TARGET_SW = {
"windows": "FortiClientWindows",
"macos": "FortiClientMac",
"mac_os_x": "FortiClientMac",
"linux": "FortiClientLinux",
"android": "FortiClientAndroid",
"ios": "FortiClientiOS",
}
# Handmade FortiToken Mobile nodes carry the platform in target_sw too.
FORTITOKEN_TARGET_SW = {
"android": "FortiTokenAndroid",
"ios": "FortiTokenIOS",
"windows": "FortiTokenMobileWP",
}
def handmade_product_name(cpe, rev):
parts = cpe.split(":")
vendor, product, target_sw = parts[3], parts[4], parts[10]
if (vendor, product) == ("fortinet", "forticlient") and target_sw in FORTICLIENT_TARGET_SW:
return FORTICLIENT_TARGET_SW[target_sw]
if (vendor, product) == ("fortinet", "fortitoken_mobile") and target_sw in FORTITOKEN_TARGET_SW:
return FORTITOKEN_TARGET_SW[target_sw]
return rev.get((vendor, product))
# --- corpus helpers ---------------------------------------------------------
def load_json_tree(root):
out = {}
for p in glob.glob(root + "/[0-9]*/**/*.json", recursive=True):
out[os.path.splitext(os.path.basename(p))[0]] = p
return out
def cvrf_statuses_empty(doc):
st = (doc.get("vulnerability") or {}).get("product_statuses")
if isinstance(st, list):
return not any((s.get("status") or {}) for s in st)
if isinstance(st, dict):
s = st.get("status") or {}
if isinstance(s, list):
return not any(x for x in s)
return not s
return True
def cvrf_cves(doc):
cves = (doc.get("vulnerability") or {}).get("cve") or []
if isinstance(cves, str):
cves = [cves]
out = []
for c in cves:
m = CVE_RE.search(c)
if m:
out.append(f"CVE-{m.group(1)}-{m.group(2)}")
return sorted(set(out))
# --- handmade rows ----------------------------------------------------------
def handmade_nodes(path):
d = json.load(open(path))
for v in d.get("vulnerabilities") or []:
for de in v.get("definitions") or []:
for c in de.get("configurations") or []:
yield from (c.get("nodes") or [])
def train_range(token):
"""'6.0' -> {ge: 6.0, lt: 6.1}; '5' -> {ge: 5, lt: 6}."""
parts = token.split(".")
nxt = parts[:-1] + [str(int(parts[-1]) + 1)]
return {"ge": token, "lt": ".".join(nxt)}
DASH_BUILD_RE = re.compile(r"^(\d+\.\d+)-(\d+)-(\d+)$")
def normalize_version(v):
"""Old Meru-style dash builds ("8.2-4-0", FortiWLC/AscenLink era) are the
dotted builds of the same product in later notation -> "8.2.4.0"."""
m = DASH_BUILD_RE.match(v)
if m:
return f"{m.group(1)}.{m.group(2)}.{m.group(3)}"
return v
def handmade_rows(aid, hm_paths, rev, table, problems, notes):
"""Rows from the handmade dataset; only nodes with a version constraint."""
if aid not in hm_paths:
return []
per_product = collections.defaultdict(lambda: {"exacts": [], "ranges": []})
for n in handmade_nodes(hm_paths[aid]):
a = n.get("affected") or {}
a = {k: normalize_version((a.get(k) or "").strip()) for k in ("eq", "gt", "ge", "lt", "le")}
if not any(a.values()):
continue
cpe = (n.get("cpe") or "").strip()
if cpe.startswith("cpe:2.3:h:"):
# Hardware appliance models (version "-"); scanners report the
# software CPE, so hardware rows are not usable for detection.
notes.append(f"{aid}: skipped hardware cpe {cpe}")
continue
name = handmade_product_name(cpe, rev)
if name is None:
problems.append(f"{aid}: handmade cpe not in product table: {cpe}")
continue
if a["eq"] == "ANY":
# Handmade notation for "every version of this product".
per_product[name]["all"] = True
continue
if ":beta" in a["eq"]:
# Handmade enumerates FortiOS 5.0 betas for FG-IR-012-003; beta
# builds are not scanner-reportable versions.
notes.append(f"{aid}: skipped beta version {a['eq']!r} for {name}")
continue
for k, v in a.items():
if v and not VER_RE.match(v):
problems.append(f"{aid}: handmade non-numeric bound {k}={v!r} for {name}")
a[k] = ""
entry = per_product[name]
if a["eq"]:
two = table[name][1]
dots = a["eq"].count(".")
if dots >= (1 if two else 2):
entry["exacts"].append(a["eq"])
else:
# A bare train as an exact enumeration covers the whole train.
entry["ranges"].append(train_range(a["eq"]))
r = {k: v for k, v in a.items() if v and k != "eq"}
if r:
entry["ranges"].append(r)
return finish_rows(per_product)
# --- CNA rows ---------------------------------------------------------------
# CNA affected[].product / free-text product tokens -> product table name(s).
# A single CNA token can stand for several products ("FortiAP-S/W2").
CNA_PRODUCT_ALIASES = {
"FortiGate": ["FortiOS"],
"FortiAnalyzer BigData": ["FortiAnalyzer-BigData"],
"FortiClient": ["FortiClientWindows"],
"FortiClient for Windows": ["FortiClientWindows"],
"FortiClient Windows": ["FortiClientWindows"],
"FortiClient for Mac": ["FortiClientMac"],
"FortiClient for Mac OS": ["FortiClientMac"],
"FortiClient for Mac OSX": ["FortiClientMac"],
"FortiClient for MacOSX": ["FortiClientMac"],
"FortiClient MacOS": ["FortiClientMac"],
"FortiClient for Linux": ["FortiClientLinux"],
"FortiClient Linux": ["FortiClientLinux"],
"FortiClient iOS": ["FortiClientiOS"],
"FortiClient for iOS": ["FortiClientiOS"],
"FortiClient Android": ["FortiClientAndroid"],
"FortiClient for Android": ["FortiClientAndroid"],
"FortiClient EMS": ["FortiClientEMS"],
"FortiClient SSLVPN Client for Linux": ["FortiClientSSLVPN"],
"FortiVoiceEnterprise": ["FortiVoice"],
"FortiAuthenticator WEB UI": ["FortiAuthenticator"],
"WindowsAgent": ["FortiSIEMWindowsAgent"],
"FSSO Windows DC Agent": ["FSSO"],
"FSSO Windows CA": ["FSSO CA"],
"FortiAP-S/W2": ["FortiAP-S", "FortiAP-W2"],
}
def cna_product_names(raw, table, problems, ctx):
"""Resolve a CNA product token to table names, or None on failure.
The token may list several products ("FortiOS and FortiProxy")."""
s = re.sub(r"\s+", " ", raw).strip().strip(",")
while s.lower().startswith("fortinet "):
s = s[len("fortinet "):]
if not s or s.lower() == "n/a":
return []
if s in CNA_PRODUCT_ALIASES:
return CNA_PRODUCT_ALIASES[s]
if s in table:
return [s]
# A product field that itself enumerates products.
parts = [p for p in re.split(r",|;|\band\b", s) if p.strip()]
if len(parts) > 1:
out = []
for p in parts:
names = cna_product_names(p, table, problems, ctx)
if names is None:
return None
out.extend(names)
return out
problems.append(f"{ctx}: unmapped CNA product name {raw!r} (normalized {s!r})")
return None
def parse_cna_version_text(text, default_products, table, problems, ctx):
"""Parse one CNA free-text version value into {product: {exacts, ranges}}.
Grammar (phrase-level, hard-listed): a stream of product-name tokens and
version phrases. A product token switches the current product(s); version
phrases attach to them. Anything outside the grammar is reported and the
whole value is rejected (rows come from a manual override instead).
"""
out = collections.defaultdict(lambda: {"exacts": [], "ranges": []})
s = re.sub(r"\s+", " ", text).strip().rstrip(".")
s = re.sub(r"\b(\d+\.\d+)-(\d+)-(\d+)\b", r"\1.\2.\3", s) # Meru dash builds
if not s:
return out
if s.lower().startswith("fixed in"):
# A fix note miscoded as an affected version entry; carries no
# affected-version information.
return out
# Tokenize into product switches and version phrases. Longest-first so
# "FortiAnalyzer-BigData" wins over "FortiAnalyzer".
prod_tokens = sorted(
set(list(CNA_PRODUCT_ALIASES) + list(table)), key=len, reverse=True)
prod_re = "|".join(re.escape(p) for p in prod_tokens)
ver = r"\d+(?:\.\d+){0,3}"
train = r"\d+(?:\.\d+)?"
below = r"(?:below|earlier|lower|prior)"
through = r"(?:through|thorugh|to)"
phrase_res = [
# "1.2.3 through 4.5.x"
(re.compile(rf"({ver})\s+{through}\s+({train})\.x\b", re.I),
lambda m: [("range", {"ge": m.group(1), "lt": train_range(m.group(2))["lt"]})]),
# "1.2.3 through 4.5.6" / "1.2.3 to 4.5.6"
(re.compile(rf"({ver})\s+{through}\s+({ver})", re.I),
lambda m: [("range", {"ge": m.group(1), "le": m.group(2)})]),
# "1.2.3 and below/earlier/lower/prior" (also "6.0.11and below")
(re.compile(rf"({ver})\s*and\s+{below}", re.I),
lambda m: [("range", {"le": m.group(1)})]),
# "1.2.3 and above/later"
(re.compile(rf"({ver})\s*and\s+(?:above|later)", re.I),
lambda m: [("range", {"ge": m.group(1)})]),
# "all versions below/before/prior to 1.2.3"
(re.compile(rf"all versions\s+(?:below|before|prior to|lower than)\s+(?:version\s+)?({ver})", re.I),
lambda m: [("range", {"lt": m.group(1)})]),
# "below/before/prior to 1.2.3"
(re.compile(rf"(?:below|before|prior to|lower than)\s+(?:version\s+)?({ver})", re.I),
lambda m: [("range", {"lt": m.group(1)})]),
# "all versions of" — a train list follows; consume the phrase only.
(re.compile(r"all versions of\b", re.I), lambda m: []),
# "all versions"
(re.compile(r"all versions\b", re.I), lambda m: [("all", None)]),
# "5.x" / "6.0.x" train wildcard
(re.compile(rf"({train})\.x\b", re.I),
lambda m: [("range", train_range(m.group(1)))]),
# bare version
(re.compile(rf"({ver})"), lambda m: [("ver", m.group(1))]),
]
pos, cur = 0, list(default_products)
while pos < len(s):
rest = s[pos:]
m = re.match(r"(?:\s+|,|;|&|\band\b|\bversions?\b)+", rest, re.I)
if m:
pos += m.end()
continue
m = re.match(prod_re, rest)
if m:
names = cna_product_names(m.group(0), table, problems, ctx)
if names is None:
return None
cur = names
pos += m.end()
continue
matched = False
for rx, mk in phrase_res:
m = rx.match(rest)
if m:
for kind, val in mk(m):
if not cur:
problems.append(f"{ctx}: version phrase with no product: {rest[:60]!r}")
return None
for name in cur:
if kind == "range":
out[name]["ranges"].append(dict(val))
elif kind == "all":
out[name]["all"] = True
else:
two = table[name][1]
if val.count(".") >= (1 if two else 2):
out[name]["exacts"].append(val)
else:
out[name]["ranges"].append(train_range(val))
pos += m.end()
matched = True
break
if not matched:
problems.append(f"{ctx}: unparsed version text at {rest[:80]!r} (full: {s[:160]!r})")
return None
return out
def structured_range(lo, lte, lt, problems, ctx):
"""CNA structured bounds -> range dict, or None on a bound outside the
grammar. A trailing ".*"/".x" on an inclusive upper bound means "any
release of that train" -> lt the next train."""
r = {}
if lo and lo not in ("unspecified", "0", "*", "n/a"):
if not VER_RE.match(lo):
problems.append(f"{ctx}: bad structured lower bound {lo!r}")
return None
r["ge"] = lo
b = lte or lt
m = re.fullmatch(r"(\d+(?:\.\d+)?)[.](?:\*|x)", b)
if m:
# CVE JSON convention: a wildcard upper bound spans the whole train
# ({version: 6.2.0, lessThan: "6.2.*"} = the entire 6.2 train), on
# either the inclusive or the exclusive field.
r["lt"] = train_range(m.group(1))["lt"]
return r
if not VER_RE.match(b):
problems.append(f"{ctx}: bad structured upper bound {b!r}")
return None
r["le" if lte else "lt"] = b
return r
def cna_rows(aid, cves, table, problems):
"""Rows from the Fortinet CNA records, or None when any part of the
advisory's CNA data falls outside the grammar (partial rows would look
complete; a manual override supplies such advisories instead)."""
per_product = collections.defaultdict(lambda: {"exacts": [], "ranges": []})
used, failed = False, False
for cve in cves:
p = os.path.join(CVELIST, cve + ".json")
if not os.path.exists(p):
continue
try:
doc = json.load(open(p))
except json.JSONDecodeError:
continue
if doc.get("cveMetadata", {}).get("assignerShortName") != "fortinet":
continue
for a in doc.get("containers", {}).get("cna", {}).get("affected", []) or []:
ctx = f"{aid}/{cve}"
defaults = cna_product_names(a.get("product", ""), table, problems, ctx)
if defaults is None:
failed = True
defaults = []
for v in a.get("versions", []) or []:
if v.get("status") not in (None, "affected"):
continue
lo = (v.get("version") or "").strip()
lte = (v.get("lessThanOrEqual") or "").strip()
lt = (v.get("lessThan") or "").strip()
if lte or lt:
if not defaults:
problems.append(f"{ctx}: structured range with no product")
failed = True
continue
r = structured_range(lo, lte, lt, problems, ctx)
if r is None:
failed = True
continue
for name in defaults:
per_product[name]["ranges"].append(dict(r))
used = True
continue
if not lo or lo.lower() in ("n/a", "unspecified"):
continue
parsed = parse_cna_version_text(lo, defaults, table, problems, ctx)
if parsed is None:
failed = True
continue
for name, e in parsed.items():
per_product[name]["exacts"].extend(e["exacts"])
per_product[name]["ranges"].extend(e["ranges"])
if e.get("all"):
per_product[name]["all"] = True
used = True
if failed:
return None
if not used:
return []
return finish_rows(per_product)
# --- manual overrides -------------------------------------------------------
# Advisory rows curated by hand for the tail neither source yields cleanly.
# Rows are hand-derived from the advisory's CVRF "Affected Products" note
# unless stated otherwise; keep a short provenance comment on every entry.
MANUAL_OVERRIDES = {
# Handmade enumerates AscenLink dash builds within the 7.1 train
# (eq 7.1-B5599); span the train instead of guessing build ordering.
"FG-IR-14-011": [
{"product": "AscenLink", "ranges": [{"ge": "7.1", "lt": "7.2"}]},
# The note also lists these (Heartbleed): FortiOS 5.0.0-5.0.6 (fixed
# 5.0.7), FortiAuthenticator 2.2/3.x, FortiMail 4.3.x/5.x, FortiADC
# E-Series 3.x. Hardware-model and versionless lines (FortiVoice
# models, FortiRecorder, FortiDDoS B-series, FortiDNS, Coyote Point)
# are not representable and stay uncovered.
{"product": "FortiOS", "ranges": [{"ge": "5.0.0", "le": "5.0.6"}]},
{"product": "FortiAuthenticator", "ranges": [{"ge": "2.2", "lt": "2.3"}, {"ge": "3.0", "lt": "4.0"}]},
{"product": "FortiMail", "ranges": [{"ge": "4.3", "lt": "4.4"}, {"ge": "5.0", "lt": "6.0"}]},
{"product": "FortiADC", "ranges": [{"ge": "3.0", "lt": "4.0"}]},
],
# Handmade: lt 7.1-b5955 (fixed within the 7.1 train); same rationale.
"FG-IR-14-018": [{"product": "AscenLink", "ranges": [{"ge": "7.1", "lt": "7.2"}]}],
# "Standalone Forticlient SSLVPN Linux client build 2312 and lower", fixed
# in build 2313. The advisory (and handmade) names only the bare build
# number, but the product versions as "major.minor.build" with one
# monotonic build sequence, and the 4.0 prefix is attested on both sides
# of this 2015 advisory: 4.0.2012/4.0.2258 (FG-IR-13-008, 2013) and
# 4.0.2328/4.0.2332 (FG-IR-16-048, 2016; 4.4 only starts at 4.4.2334 in
# FG-IR-17-214). So build 2312 is 4.0.2312, and the full-form bound is
# used — handmade's bare "le 2312" would numerically swallow every
# full-form version (4 < 2312), including the not-affected 4.0.2328.
"FG-IR-15-017": [{"product": "FortiClientSSLVPN", "ranges": [{"le": "4.0.2312"}]}],
# "FortiCASB all versions below 4.1.0"
"FG-IR-19-001": [{"product": "FortiCASB", "ranges": [{"lt": "4.1.0"}]}],
# "FortiClient for Windows (6.2.0 and earlier) / for Mac OSX (6.2.0 and earlier)"
"FG-IR-19-110": [{"product": "FortiClientMac", "ranges": [{"le": "6.2.0"}]},
{"product": "FortiClientWindows", "ranges": [{"le": "6.2.0"}]}],
# "FortiManager 6.2.1 and below"
"FG-IR-19-271": [{"product": "FortiManager", "ranges": [{"le": "6.2.1"}]}],
# "FortiAnalyzer 6.2.0 to 6.2.3, 6.0.8 and below" + same for FortiManager
"FG-IR-19-294": [{"product": "FortiAnalyzer", "ranges": [{"ge": "6.2.0", "le": "6.2.3"}, {"le": "6.0.8"}]},
{"product": "FortiManager", "ranges": [{"ge": "6.2.0", "le": "6.2.3"}, {"le": "6.0.8"}]}],
# "FortiSIEM version 5.2.8 and below"
"FG-IR-20-041": [{"product": "FortiSIEM", "ranges": [{"le": "5.2.8"}]}],
# "FortiGate Cloud Version 20.3 and below"
"FG-IR-20-193": [{"product": "FortiGate Cloud", "ranges": [{"le": "20.3"}]}],
# "FortiAuthenticator 6.3.2 and below" (the 6.2.x/6.1.x/6.0.x lines are subsumed)
"FG-IR-20-217": [{"product": "FortiAuthenticator", "ranges": [{"le": "6.3.2"}]}],
# "FortiWLC versions 8.6.0 and below" (8.5.3 line subsumed)
"FG-IR-21-001": [{"product": "FortiWLC", "ranges": [{"le": "8.6.0"}]}],
# "FortiExtender version 7.0.1 and below" (4.2.3/4.1.7 lines subsumed)
"FG-IR-21-148": [{"product": "FortiExtender", "ranges": [{"le": "7.0.1"}]}],
# "FortiWeb 6.4.1 and below" (older lines subsumed)
"FG-IR-21-158": [{"product": "FortiWeb", "ranges": [{"le": "6.4.1"}]}],
# "FortiWeb version 6.4.1 and below / 6.3.15 and below" (subsumed)
"FG-IR-21-166": [{"product": "FortiWeb", "ranges": [{"le": "6.4.1"}]}],
# "FortiWeb version 5.9.0 through 5.9.1 / 6.0.0-6.0.7 / 6.1.0-6.1.2 /
# 6.2.0-6.2.6 / 6.3.0-6.3.16 / 6.4.0-6.4.1"
"FG-IR-21-180": [{"product": "FortiWeb", "ranges": [
{"ge": "5.9.0", "le": "5.9.1"}, {"ge": "6.0.0", "le": "6.0.7"},
{"ge": "6.1.0", "le": "6.1.2"}, {"ge": "6.2.0", "le": "6.2.6"},
{"ge": "6.3.0", "le": "6.3.16"}, {"ge": "6.4.0", "le": "6.4.1"}]}],
}
# Advisories whose handmade rows contradict the advisory text; use CNA only.
HANDMADE_EXCLUDED = {
# Handmade lists FortiOS, but the advisory's Affected Products note (and
# the CNA record) lists only FortiManager/FortiAnalyzer.
"FG-IR-21-206",
# Handmade inverted the FortiDeceptor 3.0/3.1 bounds (ge 3.0.2 le 3.0.0,
# ge 3.1.1 le 3.1.0) and marked 3.3.3 fixed where the note says affected
# ("3.3.0 through 3.3.3"); the CNA record matches the Affected Products
# note exactly and carries more FortiSandbox ranges than handmade.
"FG-IR-22-056",
# Handmade lists FortiOS 5.0.3-5.0.5, unrelated to this advisory; the CVE
# record (CVE-2019-15703) reads "FortiOS 6.2.1, 6.2.0, 6.0.8 and below",
# which is what the CNA rows carry.
"FG-IR-19-186",
# Handmade has an unbounded FortiOS row, but CVE-2020-15936 (fortinet CNA)
# bounds the SNI bypass at 6.4.3/6.2.5/6.0.11/5.6.13 and below; use the
# CNA enumeration.
"FG-IR-20-091",
# TCP timestamp responses (RFC1323, no CVE): protocol behaviour with a CLI
# hardening toggle, not a product defect with a fixed version — the
# unbounded handmade FortiOS row would flag every FortiOS forever.
# Content-only (BlackNurse precedent).
"FG-IR-16-090",
# WPA2 PMKID roaming attack: protocol-level, affects only a special
# configuration (802.11r + wpa2-only-personal), mitigation is config
# guidance, no Fortinet CVE or fixed version. Content-only.
"FG-IR-18-199",
# IKEv1 Bleichenbacher / PSK dictionary attack (CVE-2018-5389, certcc):
# third-party protocol CVE, config-dependent, mitigation-only.
# Content-only.
"FG-IR-18-214",
}
# Per-(advisory, product) row replacements for rows a source got wrong while
# the rest of the advisory's rows are fine. Applied after the union merge.
PRODUCT_ROW_FIXUPS = {
# The Affected Products note reads "FortiWAN version 4.5.0 through 4.5.9 /
# 4.4 all versions / 4.3 all versions"; handmade corrupted the 4.3 row
# into the inverted "ge 4.4.0, le 4.3.1" and narrowed 4.4 to eq 4.4.0.
# Model 4.3/4.4 as trains, and keep handmade's extra 4.0-4.2 ranges (they
# match its per-train style elsewhere and only widen coverage for EOL
# trains of this discontinued product).
("FG-IR-22-059", "FortiWAN"): [{
"product": "FortiWAN",
"ranges": [{"ge": "4.0.0", "le": "4.0.6"}, {"ge": "4.1.0", "le": "4.1.3"},
{"ge": "4.2.0", "le": "4.2.7"}, {"ge": "4.3.0", "lt": "4.4"},
{"ge": "4.4.0", "lt": "4.5"}, {"ge": "4.5.0", "le": "4.5.9"}],
}],
# The note reads "5.2 and below" (like FG-IR-17-172, where handmade used a
# bare le); handmade's ge 5.2.0 cut off the older trains the note includes.
("FG-IR-17-245", "FortiOS"): [{
"product": "FortiOS",
"ranges": [{"le": "5.2.15"}, {"ge": "5.4.0", "le": "5.4.8"},
{"ge": "5.6.0", "le": "5.6.2"}],
}],
# The note names no versions ("All FortiClient for Windows which has
# Vulnerability scan features enabled"). The CNA record exists in two
# forms that disagree: the older text form says "6.0.4 and earlier"
# (twice) and the Solutions note names 6.0.5 as the upgrade target, while
# the newer structured form says lessThanOrEqual 6.0.5 — the same
# fixed-release-in-range slip as FG-IR-22-061. Keep handmade's le 6.0.4.
("FG-IR-18-108", "FortiClientWindows"): [{
"product": "FortiClientWindows", "ranges": [{"le": "6.0.4"}],
}],
# The note hedges with "At least ... 6.0.0 through 6.0.4"; the CNA record
# additionally lists 5.6.0 through 5.6.11 for both products.
("FG-IR-18-232", "FortiAnalyzer"): [{
"product": "FortiAnalyzer",
"ranges": [{"ge": "5.6.0", "le": "5.6.11"}, {"ge": "6.0.0", "le": "6.0.4"}],
}],
("FG-IR-18-232", "FortiManager"): [{
"product": "FortiManager",
"ranges": [{"ge": "5.6.0", "le": "5.6.11"}, {"ge": "6.0.0", "le": "6.0.4"}],
}],
# The note reads "FortiOS 5.2.0 to 5.6.10" (and 6.0.0 to 6.0.4); handmade
# started the lower range at 5.6.0 and dropped 5.2.0-5.4.x.
("FG-IR-19-034", "FortiOS"): [{
"product": "FortiOS",
"ranges": [{"ge": "5.2.0", "le": "5.6.10"}, {"ge": "6.0.0", "le": "6.0.4"}],
}],
# The note reads "6.0.10 and below" (CNA agrees); handmade wrote le 6.0.11,
# pulling the fixed release into the affected range.
("FG-IR-20-082", "FortiOS"): [{
"product": "FortiOS",
"ranges": [{"le": "5.6.12"}, {"ge": "6.0.0", "le": "6.0.10"},
{"ge": "6.2.0", "le": "6.2.4"}, {"ge": "6.4.0", "le": "6.4.1"}],
}],
# Same le 6.0.11 slip as FG-IR-20-082 ("6.0.10 and below" per the note).
("FG-IR-20-103", "FortiOS"): [{
"product": "FortiOS",
"ranges": [{"ge": "6.0.0", "le": "6.0.10"}, {"ge": "6.2.0", "le": "6.2.4"},
{"ge": "6.4.0", "le": "6.4.1"}],
}],
# The note reads "5.6.x and 6.0.x are also impacted" and the CNA record
# enumerates through 6.0.14; handmade stopped the 6.0 train at 6.0.13.
("FG-IR-20-158", "FortiOS"): [{
"product": "FortiOS", "versions": ["7.0.0"],
"ranges": [{"ge": "5.6.0", "le": "5.6.14"}, {"ge": "6.0.0", "le": "6.0.14"},
{"ge": "6.2.0", "le": "6.2.9"}, {"ge": "6.4.0", "le": "6.4.6"}],
}],
# Handmade left both rows unbounded; CVE-2021-26088 reads "FSSO Collector
# version 5.0.295 and below" (the CNA record enumerates 5.0.294/5.0.295
# for both the DC Agent and the CA).
("FG-IR-20-191", "FSSO"): [{"product": "FSSO", "ranges": [{"le": "5.0.295"}]}],
("FG-IR-20-191", "FSSO CA"): [{"product": "FSSO CA", "ranges": [{"le": "5.0.295"}]}],
# Handmade wrote the 7.0 train as le 7.0.3, but the note ("7.0.0 through
# 7.0.2") and the CNA enumeration of CVE-2022-22299 (7.0.2, 7.0.1, 7.0.0)
# agree on 7.0.2; only the Solutions note's "upgrade to 7.0.4" is odd out.
("FG-IR-21-235", "FortiOS"): [{
"product": "FortiOS",
"ranges": [{"ge": "5.0.0", "le": "5.0.14"}, {"ge": "5.2.0", "le": "5.2.15"},
{"ge": "5.4.0", "le": "5.4.13"}, {"ge": "5.6.0", "le": "5.6.14"},
{"ge": "6.0.0", "le": "6.0.14"}, {"ge": "6.2.0", "le": "6.2.10"},
{"ge": "6.4.0", "le": "6.4.8"}, {"ge": "7.0.0", "le": "7.0.2"}],
}],
# The CNA record of CVE-2022-39947 says lessThanOrEqual 7.0.2, but the
# note ("7.0.0 through 7.0.1") and the Solutions note ("upgrade to
# FortiADC 7.0.2 or above") agree that 7.0.2 is the fixed release.
("FG-IR-22-061", "FortiADC"): [{
"product": "FortiADC",
"ranges": [{"ge": "5.4.0", "le": "5.4.5"}, {"ge": "6.0.0", "le": "6.0.4"},
{"ge": "6.1.0", "le": "6.1.6"}, {"ge": "6.2.0", "le": "6.2.3"},
{"ge": "7.0.0", "le": "7.0.1"}],
}],
# The note reads "FortiOS version 6.4.5 and below" and the Solutions note
# "Upgrade to FortiOS 6.4.6"; handmade wrote the 6.4 bound as le 6.4.6,
# pulling the fixed release into the range.
("FG-IR-21-049", "FortiOS"): [{
"product": "FortiOS", "versions": ["7.0.0"],
"ranges": [{"ge": "6.0.0", "le": "6.0.12"}, {"ge": "6.2.0", "le": "6.2.9"},
{"ge": "6.4.0", "le": "6.4.5"}],
}],
# Handmade's row is self-contradictory (le 7.0.3 with fixed_in 7.0.3) and
# the Solutions note names 7.0.3 as the FortiClientiOS upgrade target, so
# the bound is exclusive. (The note itself lists only 5.0/6.0 trains for
# iOS; the 7.0 range follows the fix version, like handmade intended.)
("FG-IR-22-059", "FortiClientiOS"): [{
"product": "FortiClientiOS", "ranges": [{"lt": "7.0.3"}],
}],
# --- Batch arbitrated against the Affected Products notes (AP-token
# coverage audit): handmade/CNA rows under-covered the note (off-by-one
# bounds, truncated enumerations). Each replacement transcribes the
# note's per-train lines; see report.md "AP-note token coverage".
("FG-IR-19-007", "FortiManager"): [{"product": "FortiManager", "ranges": [{"le": "6.2.4"}]}],
("FG-IR-19-043", "FortiOS"): [{"product": "FortiOS", "ranges": [{"lt": "6.0.4"}, {"ge": "6.2.0", "lt": "6.2.3"}]}],
("FG-IR-19-060", "FortiClientWindows"): [{"product": "FortiClientWindows", "versions": ["7.0.0"], "ranges": [{"le": "6.4.6"}]}],
("FG-IR-19-060", "FortiClientEMS"): [{"product": "FortiClientEMS", "versions": ["7.0.0"], "ranges": [{"le": "6.2.8"}, {"ge": "6.4.0", "le": "6.4.6"}]}],
("FG-IR-19-148", "FortiClientWindows"): [{"product": "FortiClientWindows", "ranges": [{"le": "6.2.1"}]}],
("FG-IR-19-179", "FortiOS"): [{"product": "FortiOS", "ranges": [{"ge": "5.4.0", "le": "6.0.8"}, {"ge": "6.2.0", "le": "6.2.1"}]}],
("FG-IR-19-238", "FortiClientLinux"): [{"product": "FortiClientLinux", "ranges": [{"le": "6.2.2"}]}],
("FG-IR-20-001", "FortiWeb"): [{"product": "FortiWeb", "versions": ["6.3.0"], "ranges": [{"le": "6.2.2"}]}],
("FG-IR-20-002", "FortiNAC"): [{"product": "FortiNAC", "ranges": [{"le": "8.7.2"}]}],
("FG-IR-20-005", "FortiManager"): [{"product": "FortiManager", "ranges": [{"ge": "6.2.0", "le": "6.2.6"}]}],
("FG-IR-20-005", "FortiAnalyzer"): [{"product": "FortiAnalyzer", "ranges": [{"ge": "6.2.0", "le": "6.2.6"}]}],
("FG-IR-20-013", "FortiADC"): [{"product": "FortiADC", "ranges": [{"le": "5.3.4"}]}],
("FG-IR-20-054", "FortiAnalyzer"): [{"product": "FortiAnalyzer", "ranges": [{"le": "6.2.5"}, {"ge": "6.4.0", "le": "6.4.1"}]}],
("FG-IR-20-125", "FortiWeb"): [{"product": "FortiWeb", "ranges": [{"le": "6.2.4"}, {"ge": "6.3.0", "le": "6.3.9"}]}],
("FG-IR-20-177", "FortiDeceptor"): [{"product": "FortiDeceptor", "versions": ["4.0.0"], "ranges": [{"le": "3.3.1"}]}],
("FG-IR-20-234", "FortiSandbox"): [{"product": "FortiSandbox", "versions": ["4.0.0"], "ranges": [{"le": "3.2.2"}]}],
("FG-IR-21-002", "FortiWLC"): [{"product": "FortiWLC", "versions": ["8.0.6"], "ranges": [
{"ge": "8.1.2", "le": "8.1.3"}, {"ge": "8.2.4", "le": "8.2.7"}, {"ge": "8.3.0", "le": "8.3.3"},
{"ge": "8.4.0", "le": "8.4.8"}, {"ge": "8.5.0", "le": "8.5.5"}, {"ge": "8.6.0", "le": "8.6.2"}]}],
# The note says "FortiNDR 7.2.1 and below", but the CNA record (max
# 7.2.0) and the Solutions note ("upgrade to FortiNDR 7.2.1") agree that
# 7.2.1 is the fixed release; the note is the outlier here.
("FG-IR-21-023", "FortiNDR"): [{"product": "FortiNDR", "ranges": [{"le": "7.2.0"}]}],
("FG-IR-21-047", "FortiWeb"): [{"product": "FortiWeb", "ranges": [{"le": "6.2.4"}, {"ge": "6.3.0", "le": "6.3.13"}]}],
("FG-IR-21-084", "FortiPortal"): [{"product": "FortiPortal", "ranges": [
{"le": "4.0.4"}, {"ge": "4.1.0", "le": "4.1.2"}, {"ge": "4.2.0", "le": "4.2.4"},
{"ge": "5.0.0", "le": "5.0.3"}, {"ge": "5.1.0", "le": "5.1.2"}, {"ge": "5.2.0", "le": "5.2.5"},
{"ge": "5.3.0", "le": "5.3.5"}, {"ge": "6.0.0", "le": "6.0.4"}]}],
("FG-IR-21-092", "FortiPortal"): [{"product": "FortiPortal", "ranges": [
{"le": "4.0.4"}, {"ge": "4.1.0", "le": "4.1.2"}, {"ge": "4.2.0", "le": "4.2.4"},
{"ge": "5.0.0", "le": "5.0.3"}, {"ge": "5.1.0", "le": "5.1.2"}, {"ge": "5.2.0", "le": "5.2.5"},
{"ge": "5.3.0", "le": "5.3.5"}, {"ge": "6.0.0", "le": "6.0.4"}]}],
("FG-IR-21-111", "FortiWLM"): [{"product": "FortiWLM", "ranges": [{"le": "8.6.2"}]}],
("FG-IR-21-129", "FortiWLM"): [{"product": "FortiWLM", "ranges": [{"le": "8.6.1"}]}],
("FG-IR-21-140", "FortiClientEMS"): [{"product": "FortiClientEMS", "ranges": [{"le": "6.4.6"}, {"ge": "7.0.0", "le": "7.0.1"}]}],
("FG-IR-21-156", "FortiWeb"): [{"product": "FortiWeb", "ranges": [
{"le": "6.1.2"}, {"ge": "6.2.0", "le": "6.2.6"}, {"ge": "6.3.0", "le": "6.3.15"}, {"ge": "6.4.0", "le": "6.4.1"}]}],
("FG-IR-21-206", "FortiAnalyzer"): [{"product": "FortiAnalyzer", "ranges": [
{"ge": "5.6.0", "le": "5.6.11"}, {"ge": "6.0.0", "le": "6.0.11"}, {"ge": "6.2.0", "le": "6.2.9"},
{"ge": "6.4.0", "le": "6.4.7"}, {"ge": "7.0.0", "le": "7.0.2"}]}],
("FG-IR-21-206", "FortiManager"): [{"product": "FortiManager", "ranges": [
{"ge": "5.6.0", "le": "5.6.11"}, {"ge": "6.0.0", "le": "6.0.11"}, {"ge": "6.2.0", "le": "6.2.9"},
{"ge": "6.4.0", "le": "6.4.7"}, {"ge": "7.0.0", "le": "7.0.2"}]}],
("FG-IR-22-050", "FortiAuthenticator"): [{"product": "FortiAuthenticator", "ranges": [
{"ge": "6.3.0", "le": "6.3.3"}, {"ge": "6.4.0", "le": "6.4.1"}]}],
# --- Batch arbitrated against NVD's CPE configurations (third source,
# independent of Fortinet). NVD covered versions our rows missed and the
# AP note agrees with NVD in each of these.
# "FortiOS 5.0.2 and prior" — handmade's gt 5.0.0 excluded 5.0.0/5.0.1.
("FG-IR-13-014", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "4.3.12"}, {"ge": "5.0.0", "le": "5.0.2"}]}],
# "FortiOS 5.0.13 and below / 5.2.4 and below" per the Solutions note's
# fixes (5.0.14, 5.2.5); handmade used exclusive bounds.
("FG-IR-16-003", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "5.0.13"}, {"ge": "5.2.0", "le": "5.2.4"}]}],
# NVD includes the whole 5.4 train up to the fix (5.4.1 is affected per
# handmade, so 5.4.0 is too).
("FG-IR-16-050", "FortiOS"): [{"product": "FortiOS", "ranges": [{"ge": "5.2.0", "le": "5.2.10"}, {"ge": "5.4.0", "le": "5.4.1"}]}],
# "FortiSwitch versions below 3.6.11, 6.0.6 and 6.2.2" — the 6.0/6.2
# entries are train bounds ("and below"), not lone releases, per NVD.
("FG-IR-19-013", "FortiSwitch"): [{"product": "FortiSwitch", "ranges": [
{"lt": "3.6.11"}, {"ge": "6.0.0", "le": "6.0.6"}, {"ge": "6.2.0", "le": "6.2.2"}]}],
# "FortiAP-U all versions below 6.0.0" is the FortiAP-U line; the FortiAP
# row must follow its own line, "FortiAP 6.0.5 and below".
("FG-IR-19-209", "FortiAP"): [{"product": "FortiAP", "ranges": [{"le": "6.0.5"}]}],
# "6.2.0 and below" / "6.0.7 and below" — handmade emitted lone versions.
("FG-IR-19-227", "FortiClientMac"): [{"product": "FortiClientMac", "ranges": [{"le": "6.2.0"}]}],
# "6.0.5 and below", "6.1.1 and below", 6.2.0 — the first two are bounds.
("FG-IR-19-265", "FortiWeb"): [{"product": "FortiWeb", "versions": ["6.2.0"], "ranges": [
{"le": "6.0.5"}, {"ge": "6.1.0", "le": "6.1.1"}]}],
# "FortiADC versions 5.x upto 5.4.3" plus 6.0.0.
("FG-IR-20-044", "FortiADC"): [{"product": "FortiADC", "versions": ["6.0.0"], "ranges": [{"le": "5.4.3"}]}],
# "3.2.1 and below" / "3.1.4 and below".
("FG-IR-20-071", "FortiSandbox"): [{"product": "FortiSandbox", "ranges": [{"le": "3.2.1"}]}],
# "6.2.3 and below" / "6.3.4 and below".
("FG-IR-20-076", "FortiWeb"): [{"product": "FortiWeb", "ranges": [{"le": "6.2.3"}, {"ge": "6.3.0", "le": "6.3.4"}]}],
# "6.4.3 and below" / "6.2.6 and below".
("FG-IR-20-189", "FortiManager"): [{"product": "FortiManager", "ranges": [{"le": "6.2.6"}, {"ge": "6.4.0", "le": "6.4.3"}]}],
# "v6.4.3 and below" / "v6.2.7 and below".
("FG-IR-20-190", "FortiManager"): [{"product": "FortiManager", "ranges": [{"le": "6.2.7"}, {"ge": "6.4.0", "le": "6.4.3"}]}],
# The note says "6.2.1 and below" but the Solutions note fixes at 6.2.1
# and NVD's configuration stops at 6.1.3 — so 6.2.1 is the fixed release
# and the 6.2 train is affected only up to 6.2.0 (NVD omits 6.2 entirely;
# the safe reading keeps 6.2.0). Lower trains per the note: 6.1.3 and
# below, 6.0.3 and below, all 5.x.
("FG-IR-20-222", "FortiADC"): [{"product": "FortiADC", "ranges": [
{"le": "6.1.3"}, {"ge": "6.2.0", "le": "6.2.0"}]}],
# "6.4.6 and below", 7.0.0.
("FG-IR-21-112", "FortiAnalyzer"): [{"product": "FortiAnalyzer", "versions": ["7.0.0"], "ranges": [{"le": "6.4.6"}]}],
# NVD includes 6.1.6 (the 6.1 train terminal) which the note's "6.1.0
# through 6.1.5" stops one short of; keep the note but extend to the
# train end, matching how the CNA encodes "all versions 6.1".
("FG-IR-21-235", "FortiADC"): [{"product": "FortiADC", "ranges": [
{"ge": "6.0.0", "le": "6.0.4"}, {"ge": "6.1.0", "le": "6.1.6"}, {"ge": "6.2.0", "le": "6.2.1"}]}],
# The note's "and below" lines are floored by its explicit "FortiMail /
# FortiVoiceEnterprise versions 5.3 and lower are not impacted" lines, so
# the trains are bounded (the CNA's bare "6.2.2 and earlier" is not);
# FortiVoice additionally lists "5.4 all versions".
("FG-IR-20-045", "FortiMail"): [{"product": "FortiMail", "ranges": [
{"ge": "5.4.0", "le": "5.4.10"}, {"ge": "6.0.0", "le": "6.0.7"}, {"ge": "6.2.0", "le": "6.2.2"}]}],
("FG-IR-20-045", "FortiVoice"): [{"product": "FortiVoice", "ranges": [
{"ge": "5.4.0", "lt": "5.5"}, {"ge": "6.0.0", "le": "6.0.1"}]}],
# --- Batch: the note's lowest line reads "X and below" (unbounded), but
# the emitted row carried a train floor; NVD encodes these floorless and
# the note never states the floor. Bottom range becomes le X.
("FG-IR-17-262", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "5.2.15"}, {"ge": "5.4.0", "le": "5.4.7"}, {"ge": "5.6.0", "le": "5.6.2"}]}],
("FG-IR-19-002", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "5.2.14"}, {"ge": "5.4.0", "le": "6.0.4"}]}],
("FG-IR-19-007", "FortiOS"): [{"product": "FortiOS", "versions": ["6.2.0"], "ranges": [{"le": "5.6.10"}, {"ge": "6.0.0", "le": "6.0.6"}]}],
("FG-IR-19-037", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "6.2.0"}]}],
("FG-IR-19-134", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "5.6.10"}, {"ge": "6.0.0", "le": "6.0.6"}, {"ge": "6.2.0", "le": "6.2.1"}]}],
("FG-IR-19-210", "FortiClientMac"): [{"product": "FortiClientMac", "ranges": [{"le": "6.2.1"}]}],
("FG-IR-19-217", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "5.6.13"}, {"ge": "6.0.0", "le": "6.0.9"}, {"ge": "6.2.0", "le": "6.2.2"}]}],
("FG-IR-19-236", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "6.0.6"}, {"ge": "6.2.0", "le": "6.2.1"}]}],
("FG-IR-19-248", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "6.0.10"}, {"ge": "6.2.0", "le": "6.2.2"}]}],
("FG-IR-19-270", "FortiIsolator"): [{"product": "FortiIsolator", "ranges": [{"le": "1.2.2"}]}],
("FG-IR-19-283", "FortiOS"): [{"product": "FortiOS", "versions": ["6.4.0"], "ranges": [{"le": "6.0.9"}, {"ge": "6.2.0", "le": "6.2.3"}]}],
("FG-IR-19-296", "FortiSIEM"): [{"product": "FortiSIEM", "ranges": [{"le": "5.2.6"}]}],
("FG-IR-20-003", "FortiAnalyzer"): [{"product": "FortiAnalyzer", "ranges": [{"le": "6.2.3"}]}],
("FG-IR-20-009", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "6.0.11"}, {"ge": "6.2.0", "le": "6.2.4"}]}],
("FG-IR-20-011", "FortiIsolator"): [{"product": "FortiIsolator", "ranges": [{"le": "2.0.1"}]}],
("FG-IR-20-016", "FortiWLC"): [{"product": "FortiWLC", "ranges": [{"le": "8.5.1"}]}],
("FG-IR-20-021", "FortiSIEMWindowsAgent"): [{"product": "FortiSIEMWindowsAgent", "ranges": [{"le": "3.1.2"}]}],
("FG-IR-20-033", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "6.0.11"}, {"ge": "6.2.0", "le": "6.2.5"}]}],
("FG-IR-20-070", "FortiSandbox"): [{"product": "FortiSandbox", "ranges": [{"le": "3.2.1"}]}],
("FG-IR-20-083", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "5.6.12"}, {"ge": "6.0.0", "le": "6.0.10"}]}],
# The note says "3.2.2 and earlier" but the Solutions note fixes at
# 3.2.2 ("upgrade to 3.2.2 or later"), so 3.2.1 is the last affected
# 3.2 release (the 3.1 pair is internally consistent: le 3.1.4, fix
# 3.1.5).
("FG-IR-20-171", "FortiSandbox"): [{"product": "FortiSandbox", "ranges": [{"le": "3.1.4"}, {"ge": "3.2.0", "le": "3.2.1"}]}],
("FG-IR-20-172", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "6.2.5"}, {"ge": "6.4.0", "le": "6.4.2"}]}],
("FG-IR-20-183", "FortiSDNConnector"): [{"product": "FortiSDNConnector", "ranges": [{"le": "1.1.7"}]}],
("FG-IR-20-198", "FortiSandbox"): [{"product": "FortiSandbox", "ranges": [{"le": "3.0.6"}, {"ge": "3.1.0", "le": "3.1.4"}, {"ge": "3.2.0", "le": "3.2.2"}]}],
("FG-IR-20-206", "FortiWeb"): [{"product": "FortiWeb", "ranges": [{"le": "6.2.4"}, {"ge": "6.3.0", "le": "6.3.14"}]}],
("FG-IR-21-069", "FortiWAN"): [{"product": "FortiWAN", "ranges": [{"le": "4.5.7"}]}],
("FG-IR-21-085", "FortiPortal"): [{"product": "FortiPortal", "ranges": [{"le": "5.2.5"}, {"ge": "5.3.0", "le": "5.3.5"}, {"ge": "6.0.0", "le": "6.0.4"}]}],
("FG-IR-21-112", "FortiManager"): [{"product": "FortiManager", "versions": ["7.0.0"], "ranges": [{"le": "6.4.6"}]}],
("FG-IR-21-114", "FortiWLM"): [{"product": "FortiWLM", "ranges": [{"le": "8.6.1"}]}],
("FG-IR-21-128", "FortiWLM"): [{"product": "FortiWLM", "ranges": [{"le": "8.4.2"}, {"ge": "8.5.0", "le": "8.5.2"}, {"ge": "8.6.0", "le": "8.6.2"}]}],
("FG-IR-21-189", "FortiWLM"): [{"product": "FortiWLM", "ranges": [{"le": "8.3.2"}, {"ge": "8.4.0", "le": "8.4.2"}, {"ge": "8.5.0", "le": "8.5.2"}, {"ge": "8.6.0", "le": "8.6.2"}]}],
("FG-IR-21-201", "FortiOS"): [{"product": "FortiOS", "ranges": [{"le": "6.0.13"}, {"ge": "6.2.0", "le": "6.2.9"}, {"ge": "6.4.0", "le": "6.4.7"}, {"ge": "7.0.0", "le": "7.0.2"}]}],
}
# Product rows the sources missed entirely for an advisory (the fixups above
# can only replace an existing product's rows); appended after the merge.
# All transcribed from the Affected Products notes.
PRODUCT_ROW_ADDITIONS = {
"FG-IR-19-007": [{"product": "FortiAnalyzer", "ranges": [{"le": "6.2.3"}]}],
"FG-IR-19-134": [{"product": "FortiProxy", "ranges": [{"ge": "1.0", "lt": "1.3"}, {"ge": "2.0.0", "le": "2.0.4"}]}],
"FG-IR-20-131": [{"product": "FortiProxy", "ranges": [{"le": "2.0.1"}]}],
"FG-IR-20-158": [{"product": "FortiProxy", "ranges": [{"le": "2.0.3"}]}],
"FG-IR-21-206": [
{"product": "FortiOS", "ranges": [{"ge": "6.0.0", "le": "6.0.14"}, {"ge": "6.2.0", "le": "6.2.10"},
{"ge": "6.4.0", "le": "6.4.8"}, {"ge": "7.0.0", "le": "7.0.5"}]},
{"product": "FortiProxy", "ranges": [{"ge": "1.0.0", "le": "1.0.7"}, {"ge": "1.1.0", "le": "1.1.6"},
{"ge": "1.2.0", "le": "1.2.13"}, {"ge": "2.0.0", "le": "2.0.8"},
{"ge": "7.0.0", "le": "7.0.3"}]},
],
# The note lists FortiAuthenticator 5.4 and 5.5 "all versions" too;
# handmade/CNA only covered 6.0+.
"FG-IR-20-078": [{"product": "FortiAuthenticator", "ranges": [{"ge": "5.4", "lt": "5.6"}]}],
"FG-IR-21-130": [{"product": "FortiADC", "ranges": [{"le": "6.2.4"}, {"ge": "7.0.0", "le": "7.0.2"}]}],
"FG-IR-21-222": [{"product": "FortiProxy", "ranges": [{"ge": "2.0.0", "le": "2.0.8"}, {"ge": "7.0.0", "le": "7.0.4"}]}],
"FG-IR-21-230": [{"product": "FortiProxy", "ranges": [{"ge": "2.0.0", "le": "2.0.7"}, {"ge": "7.0.0", "le": "7.0.1"}]}],
"FG-IR-21-235": [
{"product": "FortiADC", "ranges": [{"ge": "6.0.0", "le": "6.0.4"}, {"ge": "6.1.0", "le": "6.1.5"},
{"ge": "6.2.0", "le": "6.2.1"}]},
{"product": "FortiProxy", "ranges": [{"ge": "1.0.0", "le": "1.0.7"}, {"ge": "1.1.0", "le": "1.1.6"},
{"ge": "1.2.0", "le": "1.2.13"}, {"ge": "2.0.0", "le": "2.0.7"},
{"ge": "7.0.0", "le": "7.0.1"}]},
{"product": "FortiMail", "ranges": [{"ge": "6.4.0", "le": "6.4.5"}, {"ge": "7.0.0", "le": "7.0.2"}]},
],
}
# AP-note version tokens (x.y.z) that legitimately stay uncovered by the
# advisory's emitted rows. The coverage audit (below) flags any other
# uncovered token, which catches truncated transcriptions like FG-IR-22-050's
# missing 6.4 train. Common legitimate reasons: "below X" / "prior to X"
# bounds (X itself is fixed), explicitly NOT-impacted versions, tokenizer
# artifacts on run-together text or 4-component versions, and version
# schemes the rows deliberately do not use.
AP_TOKEN_REVIEWED = {
"FG-IR-13-008": "4.3.3 is a tokenizer prefix of the 4-component 4.3.3.445",
"FG-IR-13-018": "'prior to 4.3.7 / 5.0.5' — the tokens are the fixed releases",
"FG-IR-14-033": "'< version 5.0.7' — the token is the fixed release",
"FG-IR-15-003": "'lower than 3.2.1' — the token is the fixed release",
"FG-IR-15-010": "'lower than 5.3.5' — the token is the fixed release",
"FG-IR-16-026": "5.4.05 is a tokenizer artifact of run-together '5.4.0' + '5.2.7'",
"FG-IR-16-067": "4.3.19 and 5.0.0 are explicitly listed as not affected",
"FG-IR-17-279": "'below 6.1.0' — the token is the fixed release",
"FG-IR-18-059": "'below 5.3.0' — the token is the fixed release",
"FG-IR-18-100": "'below 6.2.2' (FortiClientMac) — the token is the fixed release",
"FG-IR-18-389": "5.4.0 is outside the note's '5.4.1 to 5.4.10'",
"FG-IR-19-001": "'below 4.1.0' — the token is the fixed release",
"FG-IR-19-043": "'before 6.2.3' — the token is the fixed release",
"FG-IR-19-185": "'below 2.7.4' — the token is the fixed release",
"FG-IR-19-194": "'below 6.4.0' — the token is the fixed release",
"FG-IR-20-049": "'below 6.3.0' — the token is the fixed release",
"FG-IR-20-068": "6.2.0/6.2.1 are explicitly listed as NOT impacted",
"FG-IR-20-191": "the note versions by bundling FOS release; the rows use the collector's own versioning",
"FG-IR-21-018": "6.4.0/6.4.1 are explicitly listed as NOT impacted",
"FG-IR-21-023": "7.2.1 is the fixed release per the CNA record and Solutions (note is the outlier)",
"FG-IR-21-043": "'6.4.3 and below are NOT impacted'",
"FG-IR-17-302": "5.4.95 is a tokenizer artifact of run-together '5.4.9' + '5.6'",
"FG-IR-22-052": "5.0.3 b0233 is a partial-build bound the version scheme cannot express",
"FG-IR-20-171": "3.2.2 is the fixed release per the Solutions note (the note's 'and earlier' is the outlier)",
}
# Upper bounds that equal the advisory's own Solutions upgrade target for the
# same product — normally a transcription slip pulling the fixed release into
# the affected range. These were arbitrated and stay: the Affected Products
# note AND the Fortinet CNA record both list the version as affected, making
# the Solutions note the odd one out (mirror of the FG-IR-22-061 case).
SOLUTIONS_BOUND_REVIEWED = {
("FG-IR-21-132", "FortiDDoS", "5.3.2"): "note and CNA both say 5.3.0 through 5.3.2 affected",
("FG-IR-21-132", "FortiDDoS", "5.4.3"): "note and CNA both say 5.4.0 through 5.4.3 affected",
("FG-IR-21-132", "FortiDDoS-CM", "5.4.3"): "note and CNA both say 5.4.0 through 5.4.3 affected",
# Per-CVE staged fixes: 6.2.2 fixes CVE-2019-9494/-9495, and this bound
# belongs to CVE-2019-9496 whose fix is 6.2.3.
("FG-IR-19-107", "FortiOS", "6.2.2"): "bound is for CVE-2019-9496 (fixed in 6.2.3); 6.2.2 is the other CVEs' fix",
}
# Whole-product rows (no versions, no ranges — they match every version of
# the product) audited against the advisory notes and CVE/NVD records; every
# whole-product row in the output must be listed here or generation reports a
# problem, so a new unbounded row cannot slip through unreviewed. The same
# pairs are hard-coded in supplement.go's wholeProductAudited (which guards
# hand edits to supplement_data.go) — keep the two lists in step.
WHOLE_PRODUCT_REVIEWED = {
# "FortiBalancer 400, 1000, 2000 and 3000. All software versions are
# affected." — fix is a patch with no version bound.
("FG-IR-14-010", "FortiBalancer"): "advisory: all software versions affected",
# POODLE (CVE-2014-3566): the note lists these products vulnerable in
# their default configuration with no version bounds (products the note
# does version — FortiAnalyzer/FortiManager/FortiAuthenticator etc. —
# carry ranges in handmade); fix is an SSLv3 config toggle.
("FG-IR-14-031", "FortiADC"): "POODLE: default-config, no version bound in the note",
("FG-IR-14-031", "FortiClientWindows"): "POODLE: default-config, no version bound in the note",
("FG-IR-14-031", "FortiDB"): "POODLE: default-config, no version bound in the note",
("FG-IR-14-031", "FortiMail"): "POODLE: default-config, no version bound in the note",
("FG-IR-14-031", "FortiOS"): "POODLE: default-config, no version bound in the note",
("FG-IR-14-031", "FortiRecorder"): "POODLE: default-config, no version bound in the note",
("FG-IR-14-031", "FortiSwitch"): "POODLE: default-config, no version bound in the note",
("FG-IR-14-031", "FortiVoice"): "POODLE: default-config, no version bound in the note",
# FREAK: "FortiMail all versions, in its default configuration".
("FG-IR-15-007", "FortiMail"): "FREAK: note says all versions in default configuration",
# Root privesc in the standalone Linux SSLVPN client. Both CVEs
# (CVE-2016-8497/-8496) are REJECTED, so no NVD detection exists and this
# row is the only coverage; the note bounds by the bundling FortiOS
# release (<= 5.4.3 / 5.4.2), which does not map to the client's own
# build numbering, and the product line is discontinued.
("FG-IR-16-041", "FortiClientSSLVPN"): "CVE rejected; bound only expressed in FortiOS-bundle versions",
("FG-IR-16-069", "FortiClientSSLVPN"): "CVE rejected; bound only expressed in FortiOS-bundle versions",
}
# Reviewed advisories that stay content-only, with the reason. Everything in
# the gap must end up supplemented or in this list; anything else is reported
# as unreviewed.
REVIEWED_CONTENT_ONLY = {
"FG-IR-012-004": "hardware appliance models only (no software versions in any source)",
"FG-IR-012-005": "hardware appliance models only (no software versions in any source)",
"FG-IR-012-006": "hardware appliance models only (no software versions in any source)",
"FG-IR-17-195": "hardware appliance models only (no software versions in any source)",
"FG-IR-17-271": "hardware appliance models only (no software versions in any source)",
"FG-IR-15-001": "third-party CVE (glibc GHOST); no Fortinet product versions in any source",
"FG-IR-15-013": "third-party CVE (Logjam); no Fortinet product versions in any source",
"FG-IR-15-015": "third-party CVE (OpenSSL); no Fortinet product versions in any source",
"FG-IR-15-024": "not a product defect (SYN flood filtering explanation)",
"FG-IR-16-002": "third-party CVE (glibc); no Fortinet product versions in any source",
"FG-IR-16-007": "third-party CVE (Badlock/Samba); no Fortinet product versions in any source",
"FG-IR-16-012": "third-party CVE (OpenSSL); no Fortinet product versions in any source",
"FG-IR-16-022": "FortiCloud service-side fix dated by day, not by product version",
"FG-IR-16-063": "third-party CVE (Dirty COW); no Fortinet product versions in any source",
"FG-IR-16-090": "not a product defect (TCP timestamp RFC1323 behaviour, config hardening; no CVE)",
"FG-IR-16-091": "not a product defect (BlackNurse ICMP behaviour)",
"FG-IR-17-205": "third-party CVE (Apache Struts); no Fortinet product versions in any source",
"FG-IR-17-212": "third-party CVE (BlueBorne); no Fortinet product versions in any source",
"FG-IR-17-249": "third-party CVE (Infineon ROCA); no Fortinet product versions in any source",
"FG-IR-17-251": "third-party CVE (Apache Tomcat); no Fortinet product versions in any source",
"FG-IR-18-046": "third-party CVE (AMD CPU flaws); no Fortinet product versions in any source",
"FG-IR-18-106": "no Fortinet device impact established (VPNFilter advisory)",
"FG-IR-18-112": "third-party CVE (BIND); no Fortinet product versions in any source",
"FG-IR-18-199": "not a product defect (WPA2 PMKID protocol attack, special-config only, mitigation-only)",
"FG-IR-18-214": "third-party protocol CVE (IKEv1 Bleichenbacher, CVE-2018-5389); config-dependent, mitigation-only",
"FG-IR-18-336": "third-party CVE (libssh); no Fortinet product versions in any source",
"FG-IR-19-180": "third-party CVE (TCP SACK); no Fortinet product versions in any source",
"FG-IR-19-222": "third-party CVE (VxWorks URGENT/11); no Fortinet product versions in any source",
"FG-IR-19-224": "third-party CVE (Bluetooth KNOB); no Fortinet product versions in any source",
"FG-IR-19-225": "third-party CVE (HTTP/2 flood); no Fortinet product versions in any source",
"FG-IR-19-292": "third-party CVE (CVE-2004-1653 SSH scanning); advisory states most products not impacted",
"FG-IR-20-035": "third-party CVE (Broadcom Kr00k); no Fortinet product versions in any source",
"FG-IR-20-036": "third-party CVE (NTP); no Fortinet product versions in any source",
"FG-IR-20-104": "third-party CVE (Treck Ripple20); no Fortinet product versions in any source",
"FG-IR-20-128": "third-party CVE (Apache httpd); no Fortinet product versions in any source",
"FG-IR-21-245": "third-party CVE (Log4j); no Fortinet product versions in any source",
"FG-IR-21-253": "third-party CVE (Apache httpd); advisory lists only NOT-impacted products",
"FG-IR-23-277": "third-party CVE (Downfall/Zenbleed); informational",
"FG-IR-23-383": "third-party research (TunnelCrack); informational",
"FG-IR-25-166": "third-party CVE (Apache Camel); informational",
"FG-IR-26-126": "covered by the fortinet-csaf dataset",
"FG-IR-26-139": "covered by the fortinet-csaf dataset",
"FG-IR-26-144": "covered by the fortinet-csaf dataset",
}
# --- cross-validation -------------------------------------------------------
def affected_predicate(row):
"""row -> f(version_token) -> bool, mirroring the extract-side semantics:
exacts match by equality, ranges by numeric comparison, a row with
neither matches everything (whole product)."""
def vkey(v):
return [int(x) for x in v.split(".")]
exacts = set(row.get("versions") or [])
ranges = row.get("ranges") or []
whole = not exacts and not ranges
def f(v):
if whole or v in exacts:
return True
k = vkey(v)
for r in ranges:
if r.get("ge") and k < vkey(r["ge"]): continue
if r.get("gt") and k <= vkey(r["gt"]): continue
if r.get("le") and k > vkey(r["le"]): continue
if r.get("lt") and k >= vkey(r["lt"]): continue
return True
return False
return f
def probes_of(row):
out = set(row.get("versions") or [])
for r in row.get("ranges") or []:
out.update(v for v in r.values())
return out
def version_level_diffs(aid, hm, cna):
"""Compare handmade vs CNA per product at version level: evaluate every
version token either side mentions against both sides' affected
predicates and report the disagreements (for human review)."""
out = []
hm_by = {r["product"]: r for r in hm}
cna_by = {r["product"]: r for r in cna}
for name in sorted(set(hm_by) & set(cna_by)):
hf, cf = affected_predicate(hm_by[name]), affected_predicate(cna_by[name])
diffs = []
for v in sorted(probes_of(hm_by[name]) | probes_of(cna_by[name]),
key=lambda s: [int(x) for x in s.split(".")]):
a, b = hf(v), cf(v)
if a != b:
diffs.append(f"{v}: handmade={'Y' if a else 'N'} cna={'Y' if b else 'N'}")
if diffs:
out.append(f"{aid}/{name}: {'; '.join(diffs)}")
return out
# --- assembly ---------------------------------------------------------------
def finish_rows(per_product):
rows = []
for name in sorted(per_product):
e = per_product[name]
row = {"product": name}
exacts = sorted(set(e["exacts"]), key=lambda v: [int(x) for x in v.split(".")])
ranges = [dict(t) for t in sorted({tuple(sorted(r.items())) for r in e["ranges"]})]
if e.get("all") and not exacts and not ranges:
pass # whole-product row: CPE only
if exacts:
row["versions"] = exacts
if ranges:
row["ranges"] = ranges
if row.get("versions") or row.get("ranges") or e.get("all"):
rows.append(row)
return rows
def emit_go(supplement, path):
"""Emit the table as a Go literal (supplement_data.go for the cvrf
package); supplement.json stays as the tooling-readable byproduct."""
def q(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
bounds = [("ge", "GreaterEqual"), ("gt", "GreaterThan"),
("le", "LessEqual"), ("lt", "LessThan")]
with open(path, "w") as f:
f.write("// The initial content of this table was machine-generated from the\n"
"// legacy handmade dataset, Fortinet's CNA records and the advisory\n"
"// notes, then arbitrated by hand; it is maintained as ordinary source.\n"
"// See supplement.go for what this table is and how it was derived.\n\n"
"package cvrf\n\n"
"var supplementTable = map[string][]supplementProduct{\n")
for aid in sorted(supplement):
f.write(f"\t{q(aid)}: {{\n")
for row in supplement[aid]:
parts = [f"Product: {q(row['product'])}"]
if row.get("versions"):
parts.append("Versions: []string{" +
", ".join(q(v) for v in row["versions"]) + "}")
if row.get("ranges"):
rs = []
for r in row["ranges"]:
rs.append("{" + ", ".join(
f"{go}: {q(r[k])}" for k, go in bounds if r.get(k)) + "}")
parts.append("Ranges: []supplementRange{" + ", ".join(rs) + "}")
# A whole-product row matches every version; carry its audit
# verdict into the generated source so the reason is visible
# where the row is read.
comment = ""
if not row.get("versions") and not row.get("ranges"):
comment = " // whole product, audited: " + \
WHOLE_PRODUCT_REVIEWED.get((aid, row["product"]), "UNAUDITED")
f.write("\t\t{" + ", ".join(parts) + "},%s\n" % comment)
f.write("\t},\n")
f.write("}\n")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--fetch-cves", action="store_true",
help="download cvelistV5 records for the gap CVEs, then exit")
args = ap.parse_args()
table = load_table()
rev = reverse_table(table)
hm_paths = load_json_tree(HM)
cvrf_paths = load_json_tree(CVRF)
gap = {} # aid -> cves
for aid, p in sorted(cvrf_paths.items()):
doc = json.load(open(p))
if cvrf_statuses_empty(doc):
gap[aid] = cvrf_cves(doc)
if args.fetch_cves:
os.makedirs(CVELIST, exist_ok=True)
cfg = []
for cves in gap.values():
for cve in cves:
dst = os.path.join(CVELIST, cve + ".json")
if os.path.exists(dst):
continue
y, n = cve.split("-")[1], int(cve.split("-")[2])
url = (f"https://raw.githubusercontent.com/CVEProject/cvelistV5/"
f"main/cves/{y}/{n // 1000}xxx/{cve}.json")
cfg += [f'url = "{url}"', f'output = "{dst}"']
if cfg:
subprocess.run(["curl", "-s", "--parallel", "--parallel-max", "16",
"--config", "-"], input="\n".join(cfg),
text=True, check=True)
print(f"cvelist records ready: {len(glob.glob(CVELIST + '/*.json'))}")
return
problems = []
notes = []
supplement = {}
src_of = {}
conflicts = []
for aid, cves in gap.items():
if aid in MANUAL_OVERRIDES:
supplement[aid] = MANUAL_OVERRIDES[aid]
src_of[aid] = "manual"
continue
hm_problems = []
hm = handmade_rows(aid, hm_paths, rev, table, hm_problems, notes)
if aid in HANDMADE_EXCLUDED:
hm, hm_problems = [], []
cna_problems = []
cna = cna_rows(aid, cves, table, cna_problems)
# Union at product level: handmade rows win for the products handmade
# covers (curated ranges); CNA fills the products handmade misses
# (handmade 2018+ often tracks only FortiOS while the CNA record also
# lists FortiProxy etc.).
hp = {r["product"] for r in hm}
if hm and cna:
conflicts += version_level_diffs(aid, hm, cna)
rows = hm + [r for r in (cna or []) if r["product"] not in hp]
rows = [row for r in rows
for row in (PRODUCT_ROW_FIXUPS.get((aid, r["product"])) or [r])]
rows += PRODUCT_ROW_ADDITIONS.get(aid, [])
if rows:
supplement[aid] = rows
src_of[aid] = ("handmade" if not cna else "cna" if not hm else "handmade+cna")
problems += hm_problems if hm else []
problems += cna_problems if cna else []
if hm and cna is None and cna_problems:
# CNA failed to parse but handmade covered the advisory: the
# failure is not fatal, but it silently disables the
# cross-check for this advisory (that is how FG-IR-21-235's
# off-by-one initially survived) — surface it for review.
notes += [f"(cna parse failed, cross-check skipped) {p}" for p in cna_problems]
if hm and cna:
cp = {r["product"] for r in cna}
if not hp & cp:
conflicts.append(
f"{aid}: DISJOINT handmade={sorted(hp)} cna={sorted(cp)} — verify against the advisory text")
elif hp != cp:
conflicts.append(
f"{aid}: products differ handmade={sorted(hp)} cna={sorted(cp)} (union emitted)")
else:
problems += hm_problems + cna_problems
# Validate the assembled table: a lower bound above the upper bound (a
# transcription slip like handmade's "ge 4.4.0, le 4.3.1") would emit an
# unsatisfiable criterion — a silent detection false negative. And every
# whole-product row (matches all versions — the biggest false-positive
# surface) must be individually audited in WHOLE_PRODUCT_REVIEWED.
def vkey(v):
return [int(x) for x in v.split(".")]
upgrade_re = re.compile(
r"[Uu]pgrade to (?:upcoming )?((?:Forti|FSSO)[\w -]*?)\s*(?:version\s*)?(\d+\.\d+\.\d+)")
for aid, rows in supplement.items():
# Product-aware Solutions check: an inclusive upper bound that equals
# the SAME product's Solutions upgrade target usually means the fixed
# release was pulled into the affected range (FG-IR-22-061's CNA did
# exactly that). Arbitrated exceptions live in SOLUTIONS_BOUND_REVIEWED.
doc = json.load(open(cvrf_paths[aid]))
sol = next((re.sub(r"\s+", " ", (n.get("text") or "").strip())
for n in ((doc.get("documentnotes") or {}).get("note") or [])
if n.get("title") == "Solutions"), "")
def pnorm(name):
# "FortiVoiceEnterprise" in Solutions is table-name "FortiVoice";
# exact matching otherwise (FortiOS-6K7K must not match FortiOS).
return re.sub(r"enterprise$", "", name.replace(" ", "").lower())
targets = {(pnorm(p), v) for p, v in upgrade_re.findall(sol)}
for r in rows:
if not r.get("versions") and not r.get("ranges") \
and (aid, r["product"]) not in WHOLE_PRODUCT_REVIEWED:
problems.append(
f"{aid}: unaudited whole-product row for {r['product']} (add to WHOLE_PRODUCT_REVIEWED or bound it)")
pkey = pnorm(r["product"])
for rg in r.get("ranges") or []:
lo, hi = rg.get("ge") or rg.get("gt"), rg.get("le") or rg.get("lt")
if lo and hi and vkey(lo) > vkey(hi):
problems.append(
f"{aid}: inverted range for {r['product']}: {rg} (lower > upper)")
ub = rg.get("le")
if ub and any(tp == pkey for tp, tv in targets if tv == ub) \
and (aid, r["product"], ub) not in SOLUTIONS_BOUND_REVIEWED:
problems.append(
f"{aid}: upper bound {ub} for {r['product']} equals its own Solutions upgrade target (fixed release in affected range? arbitrate and fix or add to SOLUTIONS_BOUND_REVIEWED)")
# AP-note token coverage audit: every x.y.z token in the Affected
# Products note must be covered by some emitted row of that advisory, or
# the advisory must be in AP_TOKEN_REVIEWED — this is what catches a
# truncated transcription (a missing train or product row).
ap_audit = []
for aid, rows in supplement.items():
doc = json.load(open(cvrf_paths[aid]))
ap = next((re.sub(r"\s+", " ", (n.get("text") or "").strip())
for n in ((doc.get("documentnotes") or {}).get("note") or [])
if n.get("title") == "Affected Products"), "")
preds = [affected_predicate(r) for r in rows]
miss = [t for t in sorted(set(re.findall(r"\b\d+\.\d+\.\d+\b", ap)),
key=lambda v: [int(x) for x in v.split(".")])
if not any(p(t) for p in preds)]
if miss:
ap_audit.append((aid, miss))
if aid not in AP_TOKEN_REVIEWED:
problems.append(
f"{aid}: AP-note tokens not covered by emitted rows: {miss} (transcription gap? arbitrate and fix or add to AP_TOKEN_REVIEWED)")
with open(os.path.join(HERE, "supplement.json"), "w") as f:
json.dump(supplement, f, indent=1, sort_keys=True)
f.write("\n")
emit_go(supplement, os.path.join(HERE, "supplement_data.go"))
by_src = collections.Counter(src_of.values())
with open(os.path.join(HERE, "report.md"), "w") as f:
f.write("# fortinet-cvrf supplement generation report\n\n")
f.write(f"- statuses-empty CVRF advisories: {len(gap)}\n")
f.write(f"- supplemented: {len(supplement)} (handmade {by_src['handmade']},"
f" cna {by_src['cna']}, handmade+cna {by_src['handmade+cna']},"
f" manual {by_src['manual']})\n")
f.write(f"- reviewed content-only: {len(set(gap) & set(REVIEWED_CONTENT_ONLY))}\n")
unreviewed = sorted(set(gap) - set(supplement) - set(REVIEWED_CONTENT_ONLY))
f.write(f"- UNREVIEWED (must be empty): {len(unreviewed)}\n\n")
f.write("## unreviewed advisories\n\n")
for aid in unreviewed:
f.write(f"- {aid} (CVEs: {', '.join(gap[aid]) or 'none'})\n")
f.write("\n## reviewed content-only advisories\n\n")
for aid in sorted(set(gap) & set(REVIEWED_CONTENT_ONLY)):
f.write(f"- {aid}: {REVIEWED_CONTENT_ONLY[aid]}\n")
f.write("\n## AP-note token coverage (uncovered tokens, with review status)\n\n")
for aid, miss in sorted(ap_audit):
f.write(f"- {aid}: {miss}{AP_TOKEN_REVIEWED.get(aid, 'UNREVIEWED')}\n")
f.write("\n## whole-product rows (audited)\n\n")
for aid, rows in sorted(supplement.items()):
for r in rows:
if not r.get("versions") and not r.get("ranges"):
reason = WHOLE_PRODUCT_REVIEWED.get((aid, r["product"]), "UNAUDITED")
f.write(f"- {aid}/{r['product']}: {reason}\n")
f.write("\n## handmade vs CNA product-set diffs\n\n")
for c in conflicts:
f.write(f"- {c}\n")
f.write("\n## problems\n\n")
for p in sorted(set(problems)):
f.write(f"- {p}\n")
f.write("\n## notes (deliberate skips)\n\n")
for p in sorted(set(notes)):
f.write(f"- {p}\n")
print(f"gap={len(gap)} supplemented={len(supplement)} "
f"(hm={by_src['handmade']} cna={by_src['cna']}) "
f"problems={len(set(problems))} conflicts={len(conflicts)}")
print(f"wrote {os.path.join(HERE, 'supplement.json')} and report.md")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment