|
#!/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() |