{"vulnerability": "cve-2019-1570", "sightings": [{"uuid": "67e90fa8-172f-44cd-bb68-08352b7e203d", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2019-15707", "type": "seen", "source": "https://bsky.app/profile/beikokucyber.bsky.social/post/3lvjlb7b5ol2f", "content": "", "creation_timestamp": "2025-08-03T21:02:26.506837Z"}, {"uuid": "9ba9f4c0-cd50-40e8-b62d-74314fbffe33", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2019-15706", "type": "seen", "source": "MISP/02fb130c-7874-4693-9b66-81ed91a2e996", "content": "", "creation_timestamp": "2025-08-12T13:33:28.000000Z"}, {"uuid": "271177e3-c309-428a-805b-fed7e2b78a37", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2019-15706", "type": "seen", "source": "MISP/02fb130c-7874-4693-9b66-81ed91a2e996", "content": "", "creation_timestamp": "2025-08-21T03:19:32.000000Z"}, {"uuid": "600fdf7b-0a25-4f2d-84c2-8bbf16fe5dba", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2019-15706", "type": "seen", "source": "https://t.me/DarkWebInformer_CVEAlerts/7773", "content": "\ud83d\udd17 DarkWebInformer.com - Cyber Threat Intelligence\n\ud83d\udccc CVE ID: CVE-2019-15706\n\ud83d\udd25 CVSS Score: 4 (cvssV3_1, Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:N/I:L/A:N/E:F/RL:X/RC:X)\n\ud83d\udd39 Description: An improper neutralization of input during web page generation in the SSL VPN portal of FortiProxy version 2.0.0, version 1.2.9 and below and FortiOS version 6.2.1 and below, version 6.0.8 and below, version 5.6.12 may allow a remote authenticated attacker to perform a stored cross site scripting attack (XSS).\n\ud83d\udccf Published: 2025-03-17T13:05:08.965Z\n\ud83d\udccf Modified: 2025-03-17T13:05:08.965Z\n\ud83d\udd17 References:\n1. https://fortiguard.fortinet.com/psirt/FG-IR-19-223", "creation_timestamp": "2025-03-17T13:47:07.000000Z"}, {"uuid": "b89a73f9-e8f9-4590-a246-6bb44edb6750", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2019-15707", "type": "published-proof-of-concept", "source": "Telegram/nMin041tCAky5Yc3vPV1PqzfXK90u6qblSscFk3GxUjBAFA", "content": "", "creation_timestamp": "2025-03-06T22:00:05.000000Z"}, {"uuid": "e2013550-d6d4-4cc9-ab8a-314b1509c143", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2019-15703", "type": "seen", "source": "https://gist.github.com/shino/5cefdef6d36283f50795e32ffcf1a51d", "content": "#!/usr/bin/env python3\n\"\"\"Generate the fortinet-cvrf supplement table.\n\nOld Fortinet CVRF advisories (2012-2021 and a few 2022) carry no\nproduct_statuses/product_tree, so `vuls-data-update extract fortinet-cvrf`\nemits them content-only. This script derives the missing affected-product data\nfor those advisories from two sources and emits a static supplement table to be\nembedded in vuls-data-update:\n\n  1. the legacy curated handmade dataset (vuls-data-raw-fortinet) \u2014 good\n     version ranges for ~2012-2018, but 2019+ entries are mostly empty and\n     sometimes list not-impacted products, so only nodes with an actual\n     version constraint are used;\n  2. Fortinet's own CNA records in cvelistV5 (containers.cna.affected) \u2014\n     covers ~2016-2022; only CVEs assigned by the \"fortinet\" CNA are used\n     (other assigners describe the upstream product, not Fortinet's).\n\nPer advisory, handmade wins when it has constrained nodes; CNA fills the rest.\nAdvisories where both exist are cross-checked and diffs land in report.md for\nhuman review.\n\nUsage:\n  ./fetch_inputs.sh\n  ./generate.py --fetch-cves   # downloads cvelistV5 records for the gap CVEs\n  ./generate.py                # writes supplement_data.go (for the cvrf package),\n                               # supplement.json (tooling byproduct), report.md\n\nThe output supplement.json maps advisory ID -&gt; product rows:\n  {\"FG-IR-012-001\": [{\"product\": \"FortiOS\", \"ranges\": [{\"ge\": \"4.3.0\", \"le\": \"4.3.5\"}]}]}\nwhere \"product\" is a key of vuls-data-update's fortinet/internal/product table\n(this script parses table.go and hard-fails on any name/CPE it cannot map).\n\"\"\"\n\nimport argparse\nimport collections\nimport glob\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\n\nHERE = os.path.dirname(os.path.abspath(__file__))\nWORK = os.path.join(HERE, \"work\")\nHM = os.path.join(WORK, \"vuls-data-raw-fortinet\")\nCVRF = os.path.join(WORK, \"vuls-data-raw-fortinet-cvrf\")\nCVELIST = os.path.join(WORK, \"cvelist\")\nTABLE_GO = os.path.normpath(os.path.join(\n    HERE, \"../../vuls-data-update/pkg/extract/fortinet/internal/product/table.go\"))\n\nCVE_RE = re.compile(r\"CVE-(?:)?(\\d{4})-(\\d+)\")\nVER_RE = re.compile(r\"^\\d+(\\.\\d+){0,3}$\")\n\n\n# --- product table ----------------------------------------------------------\n\ndef load_table():\n    \"\"\"Parse table.go: name -&gt; (cpe, twoComponentVersions).\"\"\"\n    src = open(TABLE_GO).read()\n    entries = {}\n    for m in re.finditer(\n            r'\"([^\"]+)\":\\s*\\{cpe: \"([^\"]+)\"(.*?)\\},\\n', src):\n        entries[m.group(1)] = (m.group(2), \"twoComponentVersions: true\" in m.group(3))\n    if not entries:\n        sys.exit(f\"could not parse any entries from {TABLE_GO}\")\n    return entries\n\n\ndef reverse_table(entries):\n    \"\"\"(vendor, product) -&gt; canonical product name. Handmade CPEs vary in part\n    (a: vs o:) and carry target_sw/edition qualifiers the curated table CPEs do\n    not, so match on vendor:product only. For vendor:product pairs shared by\n    several names any name resolves to the same table CPE; the plain\n    OS-agnostic-looking one is preferred via the explicit map.\"\"\"\n    prefer = {\n        (\"fortinet\", \"forticlient\"): \"FortiClientWindows\",\n    }\n    rev = {}\n    for name, (cpe, _) in sorted(entries.items()):\n        parts = cpe.split(\":\")\n        rev.setdefault((parts[3], parts[4]), name)\n    rev.update(prefer)\n    return rev\n\n\n# Handmade FortiClient nodes carry the platform in target_sw; keep it.\nFORTICLIENT_TARGET_SW = {\n    \"windows\": \"FortiClientWindows\",\n    \"macos\": \"FortiClientMac\",\n    \"mac_os_x\": \"FortiClientMac\",\n    \"linux\": \"FortiClientLinux\",\n    \"android\": \"FortiClientAndroid\",\n    \"ios\": \"FortiClientiOS\",\n}\n\n\ndef handmade_product_name(cpe, rev):\n    parts = cpe.split(\":\")\n    vendor, product, target_sw = parts[3], parts[4], parts[10]\n    if (vendor, product) == (\"fortinet\", \"forticlient\") and target_sw in FORTICLIENT_TARGET_SW:\n        return FORTICLIENT_TARGET_SW[target_sw]\n    return rev.get((vendor, product))\n\n\n# --- corpus helpers ---------------------------------------------------------\n\ndef load_json_tree(root):\n    out = {}\n    for p in glob.glob(root + \"/[0-9]*/**/*.json\", recursive=True):\n        out[os.path.splitext(os.path.basename(p))[0]] = p\n    return out\n\n\ndef cvrf_statuses_empty(doc):\n    st = (doc.get(\"vulnerability\") or {}).get(\"product_statuses\")\n    if isinstance(st, list):\n        return not any((s.get(\"status\") or {}) for s in st)\n    if isinstance(st, dict):\n        s = st.get(\"status\") or {}\n        if isinstance(s, list):\n            return not any(x for x in s)\n        return not s\n    return True\n\n\ndef cvrf_cves(doc):\n    cves = (doc.get(\"vulnerability\") or {}).get(\"cve\") or []\n    if isinstance(cves, str):\n        cves = [cves]\n    out = []\n    for c in cves:\n        m = CVE_RE.search(c)\n        if m:\n            out.append(f\"CVE-{m.group(1)}-{m.group(2)}\")\n    return sorted(set(out))\n\n\n# --- handmade rows ----------------------------------------------------------\n\ndef handmade_nodes(path):\n    d = json.load(open(path))\n    for v in d.get(\"vulnerabilities\") or []:\n        for de in v.get(\"definitions\") or []:\n            for c in de.get(\"configurations\") or []:\n                yield from (c.get(\"nodes\") or [])\n\n\ndef train_range(token):\n    \"\"\"'6.0' -&gt; {ge: 6.0, lt: 6.1}; '5' -&gt; {ge: 5, lt: 6}.\"\"\"\n    parts = token.split(\".\")\n    nxt = parts[:-1] + [str(int(parts[-1]) + 1)]\n    return {\"ge\": token, \"lt\": \".\".join(nxt)}\n\n\nDASH_BUILD_RE = re.compile(r\"^(\\d+\\.\\d+)-(\\d+)-(\\d+)$\")\n\n\ndef normalize_version(v):\n    \"\"\"Old Meru-style dash builds (\"8.2-4-0\", FortiWLC/AscenLink era) are the\n    dotted builds of the same product in later notation -&gt; \"8.2.4.0\".\"\"\"\n    m = DASH_BUILD_RE.match(v)\n    if m:\n        return f\"{m.group(1)}.{m.group(2)}.{m.group(3)}\"\n    return v\n\n\ndef handmade_rows(aid, hm_paths, rev, table, problems, notes):\n    \"\"\"Rows from the handmade dataset; only nodes with a version constraint.\"\"\"\n    if aid not in hm_paths:\n        return []\n    per_product = collections.defaultdict(lambda: {\"exacts\": [], \"ranges\": []})\n    for n in handmade_nodes(hm_paths[aid]):\n        a = n.get(\"affected\") or {}\n        a = {k: normalize_version((a.get(k) or \"\").strip()) for k in (\"eq\", \"gt\", \"ge\", \"lt\", \"le\")}\n        if not any(a.values()):\n            continue\n        cpe = (n.get(\"cpe\") or \"\").strip()\n        if cpe.startswith(\"cpe:2.3:h:\"):\n            # Hardware appliance models (version \"-\"); scanners report the\n            # software CPE, so hardware rows are not usable for detection.\n            notes.append(f\"{aid}: skipped hardware cpe {cpe}\")\n            continue\n        name = handmade_product_name(cpe, rev)\n        if name is None:\n            problems.append(f\"{aid}: handmade cpe not in product table: {cpe}\")\n            continue\n        if a[\"eq\"] == \"ANY\":\n            # Handmade notation for \"every version of this product\".\n            per_product[name][\"all\"] = True\n            continue\n        if \":beta\" in a[\"eq\"]:\n            # Handmade enumerates FortiOS 5.0 betas for FG-IR-012-003; beta\n            # builds are not scanner-reportable versions.\n            notes.append(f\"{aid}: skipped beta version {a['eq']!r} for {name}\")\n            continue\n        for k, v in a.items():\n            if v and not VER_RE.match(v):\n                problems.append(f\"{aid}: handmade non-numeric bound {k}={v!r} for {name}\")\n                a[k] = \"\"\n        entry = per_product[name]\n        if a[\"eq\"]:\n            two = table[name][1]\n            dots = a[\"eq\"].count(\".\")\n            if dots &gt;= (1 if two else 2):\n                entry[\"exacts\"].append(a[\"eq\"])\n            else:\n                # A bare train as an exact enumeration covers the whole train.\n                entry[\"ranges\"].append(train_range(a[\"eq\"]))\n        r = {k: v for k, v in a.items() if v and k != \"eq\"}\n        if r:\n            entry[\"ranges\"].append(r)\n    return finish_rows(per_product)\n\n\n# --- CNA rows ---------------------------------------------------------------\n\n# CNA affected[].product / free-text product tokens -&gt; product table name(s).\n# A single CNA token can stand for several products (\"FortiAP-S/W2\").\nCNA_PRODUCT_ALIASES = {\n    \"FortiGate\": [\"FortiOS\"],\n    \"FortiAnalyzer BigData\": [\"FortiAnalyzer-BigData\"],\n    \"FortiClient\": [\"FortiClientWindows\"],\n    \"FortiClient for Windows\": [\"FortiClientWindows\"],\n    \"FortiClient Windows\": [\"FortiClientWindows\"],\n    \"FortiClient for Mac\": [\"FortiClientMac\"],\n    \"FortiClient for Mac OS\": [\"FortiClientMac\"],\n    \"FortiClient for Mac OSX\": [\"FortiClientMac\"],\n    \"FortiClient for MacOSX\": [\"FortiClientMac\"],\n    \"FortiClient MacOS\": [\"FortiClientMac\"],\n    \"FortiClient for Linux\": [\"FortiClientLinux\"],\n    \"FortiClient Linux\": [\"FortiClientLinux\"],\n    \"FortiClient iOS\": [\"FortiClientiOS\"],\n    \"FortiClient for iOS\": [\"FortiClientiOS\"],\n    \"FortiClient Android\": [\"FortiClientAndroid\"],\n    \"FortiClient for Android\": [\"FortiClientAndroid\"],\n    \"FortiClient EMS\": [\"FortiClientEMS\"],\n    \"FortiClient SSLVPN Client for Linux\": [\"FortiClientSSLVPN\"],\n    \"FortiVoiceEnterprise\": [\"FortiVoice\"],\n    \"FortiAuthenticator WEB UI\": [\"FortiAuthenticator\"],\n    \"WindowsAgent\": [\"FortiSIEMWindowsAgent\"],\n    \"FSSO Windows DC Agent\": [\"FSSO\"],\n    \"FSSO Windows CA\": [\"FSSO CA\"],\n    \"FortiAP-S/W2\": [\"FortiAP-S\", \"FortiAP-W2\"],\n}\n\n\ndef cna_product_names(raw, table, problems, ctx):\n    \"\"\"Resolve a CNA product token to table names, or None on failure.\n    The token may list several products (\"FortiOS and FortiProxy\").\"\"\"\n    s = re.sub(r\"\\s+\", \" \", raw).strip().strip(\",\")\n    while s.lower().startswith(\"fortinet \"):\n        s = s[len(\"fortinet \"):]\n    if not s or s.lower() == \"n/a\":\n        return []\n    if s in CNA_PRODUCT_ALIASES:\n        return CNA_PRODUCT_ALIASES[s]\n    if s in table:\n        return [s]\n    # A product field that itself enumerates products.\n    parts = [p for p in re.split(r\",|;|\\band\\b\", s) if p.strip()]\n    if len(parts) &gt; 1:\n        out = []\n        for p in parts:\n            names = cna_product_names(p, table, problems, ctx)\n            if names is None:\n                return None\n            out.extend(names)\n        return out\n    problems.append(f\"{ctx}: unmapped CNA product name {raw!r} (normalized {s!r})\")\n    return None\n\n\ndef parse_cna_version_text(text, default_products, table, problems, ctx):\n    \"\"\"Parse one CNA free-text version value into {product: {exacts, ranges}}.\n\n    Grammar (phrase-level, hard-listed): a stream of product-name tokens and\n    version phrases. A product token switches the current product(s); version\n    phrases attach to them. Anything outside the grammar is reported and the\n    whole value is rejected (rows come from a manual override instead).\n    \"\"\"\n    out = collections.defaultdict(lambda: {\"exacts\": [], \"ranges\": []})\n    s = re.sub(r\"\\s+\", \" \", text).strip().rstrip(\".\")\n    s = re.sub(r\"\\b(\\d+\\.\\d+)-(\\d+)-(\\d+)\\b\", r\"\\1.\\2.\\3\", s)  # Meru dash builds\n    if not s:\n        return out\n    if s.lower().startswith(\"fixed in\"):\n        # A fix note miscoded as an affected version entry; carries no\n        # affected-version information.\n        return out\n\n    # Tokenize into product switches and version phrases. Longest-first so\n    # \"FortiAnalyzer-BigData\" wins over \"FortiAnalyzer\".\n    prod_tokens = sorted(\n        set(list(CNA_PRODUCT_ALIASES) + list(table)), key=len, reverse=True)\n    prod_re = \"|\".join(re.escape(p) for p in prod_tokens)\n    ver = r\"\\d+(?:\\.\\d+){0,3}\"\n    train = r\"\\d+(?:\\.\\d+)?\"\n    below = r\"(?:below|earlier|lower|prior)\"\n    through = r\"(?:through|thorugh|to)\"\n    phrase_res = [\n        # \"1.2.3 through 4.5.x\"\n        (re.compile(rf\"({ver})\\s+{through}\\s+({train})\\.x\\b\", re.I),\n         lambda m: [(\"range\", {\"ge\": m.group(1), \"lt\": train_range(m.group(2))[\"lt\"]})]),\n        # \"1.2.3 through 4.5.6\" / \"1.2.3 to 4.5.6\"\n        (re.compile(rf\"({ver})\\s+{through}\\s+({ver})\", re.I),\n         lambda m: [(\"range\", {\"ge\": m.group(1), \"le\": m.group(2)})]),\n        # \"1.2.3 and below/earlier/lower/prior\" (also \"6.0.11and below\")\n        (re.compile(rf\"({ver})\\s*and\\s+{below}\", re.I),\n         lambda m: [(\"range\", {\"le\": m.group(1)})]),\n        # \"1.2.3 and above/later\"\n        (re.compile(rf\"({ver})\\s*and\\s+(?:above|later)\", re.I),\n         lambda m: [(\"range\", {\"ge\": m.group(1)})]),\n        # \"all versions below/before/prior to 1.2.3\"\n        (re.compile(rf\"all versions\\s+(?:below|before|prior to|lower than)\\s+(?:version\\s+)?({ver})\", re.I),\n         lambda m: [(\"range\", {\"lt\": m.group(1)})]),\n        # \"below/before/prior to 1.2.3\"\n        (re.compile(rf\"(?:below|before|prior to|lower than)\\s+(?:version\\s+)?({ver})\", re.I),\n         lambda m: [(\"range\", {\"lt\": m.group(1)})]),\n        # \"all versions of\" \u2014 a train list follows; consume the phrase only.\n        (re.compile(r\"all versions of\\b\", re.I), lambda m: []),\n        # \"all versions\"\n        (re.compile(r\"all versions\\b\", re.I), lambda m: [(\"all\", None)]),\n        # \"5.x\" / \"6.0.x\" train wildcard\n        (re.compile(rf\"({train})\\.x\\b\", re.I),\n         lambda m: [(\"range\", train_range(m.group(1)))]),\n        # bare version\n        (re.compile(rf\"({ver})\"), lambda m: [(\"ver\", m.group(1))]),\n    ]\n\n    pos, cur = 0, list(default_products)\n    while pos &lt; len(s):\n        rest = s[pos:]\n        m = re.match(r\"(?:\\s+|,|;|&amp;|\\band\\b|\\bversions?\\b)+\", rest, re.I)\n        if m:\n            pos += m.end()\n            continue\n        m = re.match(prod_re, rest)\n        if m:\n            names = cna_product_names(m.group(0), table, problems, ctx)\n            if names is None:\n                return None\n            cur = names\n            pos += m.end()\n            continue\n        matched = False\n        for rx, mk in phrase_res:\n            m = rx.match(rest)\n            if m:\n                for kind, val in mk(m):\n                    if not cur:\n                        problems.append(f\"{ctx}: version phrase with no product: {rest[:60]!r}\")\n                        return None\n                    for name in cur:\n                        if kind == \"range\":\n                            out[name][\"ranges\"].append(dict(val))\n                        elif kind == \"all\":\n                            out[name][\"all\"] = True\n                        else:\n                            two = table[name][1]\n                            if val.count(\".\") &gt;= (1 if two else 2):\n                                out[name][\"exacts\"].append(val)\n                            else:\n                                out[name][\"ranges\"].append(train_range(val))\n                pos += m.end()\n                matched = True\n                break\n        if not matched:\n            problems.append(f\"{ctx}: unparsed version text at {rest[:80]!r} (full: {s[:160]!r})\")\n            return None\n    return out\n\n\ndef structured_range(lo, lte, lt, problems, ctx):\n    \"\"\"CNA structured bounds -&gt; range dict, or None on a bound outside the\n    grammar. A trailing \".*\"/\".x\" on an inclusive upper bound means \"any\n    release of that train\" -&gt; lt the next train.\"\"\"\n    r = {}\n    if lo and lo not in (\"unspecified\", \"0\", \"*\", \"n/a\"):\n        if not VER_RE.match(lo):\n            problems.append(f\"{ctx}: bad structured lower bound {lo!r}\")\n            return None\n        r[\"ge\"] = lo\n    b = lte or lt\n    m = re.fullmatch(r\"(\\d+(?:\\.\\d+)?)[.](?:\\*|x)\", b)\n    if m:\n        # CVE JSON convention: a wildcard upper bound spans the whole train\n        # ({version: 6.2.0, lessThan: \"6.2.*\"} = the entire 6.2 train), on\n        # either the inclusive or the exclusive field.\n        r[\"lt\"] = train_range(m.group(1))[\"lt\"]\n        return r\n    if not VER_RE.match(b):\n        problems.append(f\"{ctx}: bad structured upper bound {b!r}\")\n        return None\n    r[\"le\" if lte else \"lt\"] = b\n    return r\n\n\ndef cna_rows(aid, cves, table, problems):\n    \"\"\"Rows from the Fortinet CNA records, or None when any part of the\n    advisory's CNA data falls outside the grammar (partial rows would look\n    complete; a manual override supplies such advisories instead).\"\"\"\n    per_product = collections.defaultdict(lambda: {\"exacts\": [], \"ranges\": []})\n    used, failed = False, False\n    for cve in cves:\n        p = os.path.join(CVELIST, cve + \".json\")\n        if not os.path.exists(p):\n            continue\n        try:\n            doc = json.load(open(p))\n        except json.JSONDecodeError:\n            continue\n        if doc.get(\"cveMetadata\", {}).get(\"assignerShortName\") != \"fortinet\":\n            continue\n        for a in doc.get(\"containers\", {}).get(\"cna\", {}).get(\"affected\", []) or []:\n            ctx = f\"{aid}/{cve}\"\n            defaults = cna_product_names(a.get(\"product\", \"\"), table, problems, ctx)\n            if defaults is None:\n                failed = True\n                defaults = []\n            for v in a.get(\"versions\", []) or []:\n                if v.get(\"status\") not in (None, \"affected\"):\n                    continue\n                lo = (v.get(\"version\") or \"\").strip()\n                lte = (v.get(\"lessThanOrEqual\") or \"\").strip()\n                lt = (v.get(\"lessThan\") or \"\").strip()\n                if lte or lt:\n                    if not defaults:\n                        problems.append(f\"{ctx}: structured range with no product\")\n                        failed = True\n                        continue\n                    r = structured_range(lo, lte, lt, problems, ctx)\n                    if r is None:\n                        failed = True\n                        continue\n                    for name in defaults:\n                        per_product[name][\"ranges\"].append(dict(r))\n                    used = True\n                    continue\n                if not lo or lo.lower() in (\"n/a\", \"unspecified\"):\n                    continue\n                parsed = parse_cna_version_text(lo, defaults, table, problems, ctx)\n                if parsed is None:\n                    failed = True\n                    continue\n                for name, e in parsed.items():\n                    per_product[name][\"exacts\"].extend(e[\"exacts\"])\n                    per_product[name][\"ranges\"].extend(e[\"ranges\"])\n                    if e.get(\"all\"):\n                        per_product[name][\"all\"] = True\n                    used = True\n    if failed:\n        return None\n    if not used:\n        return []\n    return finish_rows(per_product)\n\n\n# --- manual overrides -------------------------------------------------------\n\n# Advisory rows curated by hand for the tail neither source yields cleanly.\n# Rows are hand-derived from the advisory's CVRF \"Affected Products\" note\n# unless stated otherwise; keep a short provenance comment on every entry.\nMANUAL_OVERRIDES = {\n    # Handmade enumerates AscenLink dash builds within the 7.1 train\n    # (eq 7.1-B5599); span the train instead of guessing build ordering.\n    \"FG-IR-14-011\": [{\"product\": \"AscenLink\", \"ranges\": [{\"ge\": \"7.1\", \"lt\": \"7.2\"}]}],\n    # Handmade: lt 7.1-b5955 (fixed within the 7.1 train); same rationale.\n    \"FG-IR-14-018\": [{\"product\": \"AscenLink\", \"ranges\": [{\"ge\": \"7.1\", \"lt\": \"7.2\"}]}],\n    # \"Standalone Forticlient SSLVPN Linux client build 2312 and lower\", fixed\n    # in build 2313. The advisory (and handmade) names only the bare build\n    # number, but the product versions as \"major.minor.build\" with one\n    # monotonic build sequence, and the 4.0 prefix is attested on both sides\n    # of this 2015 advisory: 4.0.2012/4.0.2258 (FG-IR-13-008, 2013) and\n    # 4.0.2328/4.0.2332 (FG-IR-16-048, 2016; 4.4 only starts at 4.4.2334 in\n    # FG-IR-17-214). So build 2312 is 4.0.2312, and the full-form bound is\n    # used \u2014 handmade's bare \"le 2312\" would numerically swallow every\n    # full-form version (4 &lt; 2312), including the not-affected 4.0.2328.\n    \"FG-IR-15-017\": [{\"product\": \"FortiClientSSLVPN\", \"ranges\": [{\"le\": \"4.0.2312\"}]}],\n    # \"FortiCASB all versions below 4.1.0\"\n    \"FG-IR-19-001\": [{\"product\": \"FortiCASB\", \"ranges\": [{\"lt\": \"4.1.0\"}]}],\n    # \"FortiClient for Windows (6.2.0 and earlier) / for Mac OSX (6.2.0 and earlier)\"\n    \"FG-IR-19-110\": [{\"product\": \"FortiClientMac\", \"ranges\": [{\"le\": \"6.2.0\"}]},\n                     {\"product\": \"FortiClientWindows\", \"ranges\": [{\"le\": \"6.2.0\"}]}],\n    # \"FortiManager 6.2.1 and below\"\n    \"FG-IR-19-271\": [{\"product\": \"FortiManager\", \"ranges\": [{\"le\": \"6.2.1\"}]}],\n    # \"FortiAnalyzer 6.2.0 to 6.2.3, 6.0.8 and below\" + same for FortiManager\n    \"FG-IR-19-294\": [{\"product\": \"FortiAnalyzer\", \"ranges\": [{\"ge\": \"6.2.0\", \"le\": \"6.2.3\"}, {\"le\": \"6.0.8\"}]},\n                     {\"product\": \"FortiManager\", \"ranges\": [{\"ge\": \"6.2.0\", \"le\": \"6.2.3\"}, {\"le\": \"6.0.8\"}]}],\n    # \"FortiSIEM version 5.2.8 and below\"\n    \"FG-IR-20-041\": [{\"product\": \"FortiSIEM\", \"ranges\": [{\"le\": \"5.2.8\"}]}],\n    # \"FortiGate Cloud Version 20.3 and below\"\n    \"FG-IR-20-193\": [{\"product\": \"FortiGate Cloud\", \"ranges\": [{\"le\": \"20.3\"}]}],\n    # \"FortiAuthenticator 6.3.2 and below\" (the 6.2.x/6.1.x/6.0.x lines are subsumed)\n    \"FG-IR-20-217\": [{\"product\": \"FortiAuthenticator\", \"ranges\": [{\"le\": \"6.3.2\"}]}],\n    # \"FortiWLC versions 8.6.0 and below\" (8.5.3 line subsumed)\n    \"FG-IR-21-001\": [{\"product\": \"FortiWLC\", \"ranges\": [{\"le\": \"8.6.0\"}]}],\n    # \"FortiExtender version 7.0.1 and below\" (4.2.3/4.1.7 lines subsumed)\n    \"FG-IR-21-148\": [{\"product\": \"FortiExtender\", \"ranges\": [{\"le\": \"7.0.1\"}]}],\n    # \"FortiWeb 6.4.1 and below\" (older lines subsumed)\n    \"FG-IR-21-158\": [{\"product\": \"FortiWeb\", \"ranges\": [{\"le\": \"6.4.1\"}]}],\n    # \"FortiWeb version 6.4.1 and below / 6.3.15 and below\" (subsumed)\n    \"FG-IR-21-166\": [{\"product\": \"FortiWeb\", \"ranges\": [{\"le\": \"6.4.1\"}]}],\n    # \"FortiWeb version 5.9.0 through 5.9.1 / 6.0.0-6.0.7 / 6.1.0-6.1.2 /\n    #  6.2.0-6.2.6 / 6.3.0-6.3.16 / 6.4.0-6.4.1\"\n    \"FG-IR-21-180\": [{\"product\": \"FortiWeb\", \"ranges\": [\n        {\"ge\": \"5.9.0\", \"le\": \"5.9.1\"}, {\"ge\": \"6.0.0\", \"le\": \"6.0.7\"},\n        {\"ge\": \"6.1.0\", \"le\": \"6.1.2\"}, {\"ge\": \"6.2.0\", \"le\": \"6.2.6\"},\n        {\"ge\": \"6.3.0\", \"le\": \"6.3.16\"}, {\"ge\": \"6.4.0\", \"le\": \"6.4.1\"}]}],\n}\n\n# Advisories whose handmade rows contradict the advisory text; use CNA only.\nHANDMADE_EXCLUDED = {\n    # Handmade lists FortiOS, but the advisory's Affected Products note (and\n    # the CNA record) lists only FortiManager/FortiAnalyzer.\n    \"FG-IR-21-206\",\n    # Handmade inverted the FortiDeceptor 3.0/3.1 bounds (ge 3.0.2 le 3.0.0,\n    # ge 3.1.1 le 3.1.0) and marked 3.3.3 fixed where the note says affected\n    # (\"3.3.0 through 3.3.3\"); the CNA record matches the Affected Products\n    # note exactly and carries more FortiSandbox ranges than handmade.\n    \"FG-IR-22-056\",\n    # Handmade lists FortiOS 5.0.3-5.0.5, unrelated to this advisory; the CVE\n    # record (CVE-2019-15703) reads \"FortiOS 6.2.1, 6.2.0, 6.0.8 and below\",\n    # which is what the CNA rows carry.\n    \"FG-IR-19-186\",\n}\n\n# Per-(advisory, product) row replacements for rows a source got wrong while\n# the rest of the advisory's rows are fine. Applied after the union merge.\nPRODUCT_ROW_FIXUPS = {\n    # The Affected Products note reads \"FortiWAN version 4.5.0 through 4.5.9 /\n    # 4.4 all versions / 4.3 all versions\"; handmade corrupted the 4.3 row\n    # into the inverted \"ge 4.4.0, le 4.3.1\" and narrowed 4.4 to eq 4.4.0.\n    # Model 4.3/4.4 as trains, and keep handmade's extra 4.0-4.2 ranges (they\n    # match its per-train style elsewhere and only widen coverage for EOL\n    # trains of this discontinued product).\n    (\"FG-IR-22-059\", \"FortiWAN\"): [{\n        \"product\": \"FortiWAN\",\n        \"ranges\": [{\"ge\": \"4.0.0\", \"le\": \"4.0.6\"}, {\"ge\": \"4.1.0\", \"le\": \"4.1.3\"},\n                   {\"ge\": \"4.2.0\", \"le\": \"4.2.7\"}, {\"ge\": \"4.3.0\", \"lt\": \"4.4\"},\n                   {\"ge\": \"4.4.0\", \"lt\": \"4.5\"}, {\"ge\": \"4.5.0\", \"le\": \"4.5.9\"}],\n    }],\n    # The note reads \"5.2 and below\" (like FG-IR-17-172, where handmade used a\n    # bare le); handmade's ge 5.2.0 cut off the older trains the note includes.\n    (\"FG-IR-17-245\", \"FortiOS\"): [{\n        \"product\": \"FortiOS\",\n        \"ranges\": [{\"le\": \"5.2.15\"}, {\"ge\": \"5.4.0\", \"le\": \"5.4.8\"},\n                   {\"ge\": \"5.6.0\", \"le\": \"5.6.2\"}],\n    }],\n    # The note names no versions (\"All FortiClient for Windows which has\n    # Vulnerability scan features enabled\"); the CNA record extends handmade's\n    # le 6.0.4 to 6.0.5.\n    (\"FG-IR-18-108\", \"FortiClientWindows\"): [{\n        \"product\": \"FortiClientWindows\", \"ranges\": [{\"le\": \"6.0.5\"}],\n    }],\n    # The note hedges with \"At least ... 6.0.0 through 6.0.4\"; the CNA record\n    # additionally lists 5.6.0 through 5.6.11 for both products.\n    (\"FG-IR-18-232\", \"FortiAnalyzer\"): [{\n        \"product\": \"FortiAnalyzer\",\n        \"ranges\": [{\"ge\": \"5.6.0\", \"le\": \"5.6.11\"}, {\"ge\": \"6.0.0\", \"le\": \"6.0.4\"}],\n    }],\n    (\"FG-IR-18-232\", \"FortiManager\"): [{\n        \"product\": \"FortiManager\",\n        \"ranges\": [{\"ge\": \"5.6.0\", \"le\": \"5.6.11\"}, {\"ge\": \"6.0.0\", \"le\": \"6.0.4\"}],\n    }],\n    # The note reads \"FortiOS 5.2.0 to 5.6.10\" (and 6.0.0 to 6.0.4); handmade\n    # started the lower range at 5.6.0 and dropped 5.2.0-5.4.x.\n    (\"FG-IR-19-034\", \"FortiOS\"): [{\n        \"product\": \"FortiOS\",\n        \"ranges\": [{\"ge\": \"5.2.0\", \"le\": \"5.6.10\"}, {\"ge\": \"6.0.0\", \"le\": \"6.0.4\"}],\n    }],\n    # The note reads \"6.0.10 and below\" (CNA agrees); handmade wrote le 6.0.11,\n    # pulling the fixed release into the affected range.\n    (\"FG-IR-20-082\", \"FortiOS\"): [{\n        \"product\": \"FortiOS\",\n        \"ranges\": [{\"ge\": \"5.6.0\", \"le\": \"5.6.12\"}, {\"ge\": \"6.0.0\", \"le\": \"6.0.10\"},\n                   {\"ge\": \"6.2.0\", \"le\": \"6.2.4\"}, {\"ge\": \"6.4.0\", \"le\": \"6.4.1\"}],\n    }],\n    # Same le 6.0.11 slip as FG-IR-20-082 (\"6.0.10 and below\" per the note).\n    (\"FG-IR-20-103\", \"FortiOS\"): [{\n        \"product\": \"FortiOS\",\n        \"ranges\": [{\"ge\": \"6.0.0\", \"le\": \"6.0.10\"}, {\"ge\": \"6.2.0\", \"le\": \"6.2.4\"},\n                   {\"ge\": \"6.4.0\", \"le\": \"6.4.1\"}],\n    }],\n    # The note reads \"5.6.x and 6.0.x are also impacted\" and the CNA record\n    # enumerates through 6.0.14; handmade stopped the 6.0 train at 6.0.13.\n    (\"FG-IR-20-158\", \"FortiOS\"): [{\n        \"product\": \"FortiOS\", \"versions\": [\"7.0.0\"],\n        \"ranges\": [{\"ge\": \"5.6.0\", \"le\": \"5.6.14\"}, {\"ge\": \"6.0.0\", \"le\": \"6.0.14\"},\n                   {\"ge\": \"6.2.0\", \"le\": \"6.2.9\"}, {\"ge\": \"6.4.0\", \"le\": \"6.4.6\"}],\n    }],\n}\n\n# Reviewed advisories that stay content-only, with the reason. Everything in\n# the gap must end up supplemented or in this list; anything else is reported\n# as unreviewed.\nREVIEWED_CONTENT_ONLY = {\n    \"FG-IR-012-004\": \"hardware appliance models only (no software versions in any source)\",\n    \"FG-IR-012-005\": \"hardware appliance models only (no software versions in any source)\",\n    \"FG-IR-012-006\": \"hardware appliance models only (no software versions in any source)\",\n    \"FG-IR-17-195\": \"hardware appliance models only (no software versions in any source)\",\n    \"FG-IR-17-271\": \"hardware appliance models only (no software versions in any source)\",\n    \"FG-IR-15-001\": \"third-party CVE (glibc GHOST); no Fortinet product versions in any source\",\n    \"FG-IR-15-013\": \"third-party CVE (Logjam); no Fortinet product versions in any source\",\n    \"FG-IR-15-015\": \"third-party CVE (OpenSSL); no Fortinet product versions in any source\",\n    \"FG-IR-15-024\": \"not a product defect (SYN flood filtering explanation)\",\n    \"FG-IR-16-002\": \"third-party CVE (glibc); no Fortinet product versions in any source\",\n    \"FG-IR-16-007\": \"third-party CVE (Badlock/Samba); no Fortinet product versions in any source\",\n    \"FG-IR-16-012\": \"third-party CVE (OpenSSL); no Fortinet product versions in any source\",\n    \"FG-IR-16-022\": \"FortiCloud service-side fix dated by day, not by product version\",\n    \"FG-IR-16-063\": \"third-party CVE (Dirty COW); no Fortinet product versions in any source\",\n    \"FG-IR-16-091\": \"not a product defect (BlackNurse ICMP behaviour)\",\n    \"FG-IR-17-205\": \"third-party CVE (Apache Struts); no Fortinet product versions in any source\",\n    \"FG-IR-17-212\": \"third-party CVE (BlueBorne); no Fortinet product versions in any source\",\n    \"FG-IR-17-249\": \"third-party CVE (Infineon ROCA); no Fortinet product versions in any source\",\n    \"FG-IR-17-251\": \"third-party CVE (Apache Tomcat); no Fortinet product versions in any source\",\n    \"FG-IR-18-046\": \"third-party CVE (AMD CPU flaws); no Fortinet product versions in any source\",\n    \"FG-IR-18-106\": \"no Fortinet device impact established (VPNFilter advisory)\",\n    \"FG-IR-18-112\": \"third-party CVE (BIND); no Fortinet product versions in any source\",\n    \"FG-IR-18-336\": \"third-party CVE (libssh); no Fortinet product versions in any source\",\n    \"FG-IR-19-180\": \"third-party CVE (TCP SACK); no Fortinet product versions in any source\",\n    \"FG-IR-19-222\": \"third-party CVE (VxWorks URGENT/11); no Fortinet product versions in any source\",\n    \"FG-IR-19-224\": \"third-party CVE (Bluetooth KNOB); no Fortinet product versions in any source\",\n    \"FG-IR-19-225\": \"third-party CVE (HTTP/2 flood); no Fortinet product versions in any source\",\n    \"FG-IR-19-292\": \"third-party CVE (CVE-2004-1653 SSH scanning); advisory states most products not impacted\",\n    \"FG-IR-20-035\": \"third-party CVE (Broadcom Kr00k); no Fortinet product versions in any source\",\n    \"FG-IR-20-036\": \"third-party CVE (NTP); no Fortinet product versions in any source\",\n    \"FG-IR-20-104\": \"third-party CVE (Treck Ripple20); no Fortinet product versions in any source\",\n    \"FG-IR-20-128\": \"third-party CVE (Apache httpd); no Fortinet product versions in any source\",\n    \"FG-IR-21-245\": \"third-party CVE (Log4j); no Fortinet product versions in any source\",\n    \"FG-IR-21-253\": \"third-party CVE (Apache httpd); advisory lists only NOT-impacted products\",\n    \"FG-IR-23-277\": \"third-party CVE (Downfall/Zenbleed); informational\",\n    \"FG-IR-23-383\": \"third-party research (TunnelCrack); informational\",\n    \"FG-IR-25-166\": \"third-party CVE (Apache Camel); informational\",\n    \"FG-IR-26-126\": \"covered by the fortinet-csaf dataset\",\n    \"FG-IR-26-139\": \"covered by the fortinet-csaf dataset\",\n    \"FG-IR-26-144\": \"covered by the fortinet-csaf dataset\",\n}\n\n\n# --- cross-validation -------------------------------------------------------\n\ndef affected_predicate(row):\n    \"\"\"row -&gt; f(version_token) -&gt; bool, mirroring the extract-side semantics:\n    exacts match by equality, ranges by numeric comparison, a row with\n    neither matches everything (whole product).\"\"\"\n    def vkey(v):\n        return [int(x) for x in v.split(\".\")]\n    exacts = set(row.get(\"versions\") or [])\n    ranges = row.get(\"ranges\") or []\n    whole = not exacts and not ranges\n\n    def f(v):\n        if whole or v in exacts:\n            return True\n        k = vkey(v)\n        for r in ranges:\n            if r.get(\"ge\") and k &lt; vkey(r[\"ge\"]): continue\n            if r.get(\"gt\") and k &lt;= vkey(r[\"gt\"]): continue\n            if r.get(\"le\") and k &gt; vkey(r[\"le\"]): continue\n            if r.get(\"lt\") and k &gt;= vkey(r[\"lt\"]): continue\n            return True\n        return False\n    return f\n\n\ndef probes_of(row):\n    out = set(row.get(\"versions\") or [])\n    for r in row.get(\"ranges\") or []:\n        out.update(v for v in r.values())\n    return out\n\n\ndef version_level_diffs(aid, hm, cna):\n    \"\"\"Compare handmade vs CNA per product at version level: evaluate every\n    version token either side mentions against both sides' affected\n    predicates and report the disagreements (for human review).\"\"\"\n    out = []\n    hm_by = {r[\"product\"]: r for r in hm}\n    cna_by = {r[\"product\"]: r for r in cna}\n    for name in sorted(set(hm_by) &amp; set(cna_by)):\n        hf, cf = affected_predicate(hm_by[name]), affected_predicate(cna_by[name])\n        diffs = []\n        for v in sorted(probes_of(hm_by[name]) | probes_of(cna_by[name]),\n                        key=lambda s: [int(x) for x in s.split(\".\")]):\n            a, b = hf(v), cf(v)\n            if a != b:\n                diffs.append(f\"{v}: handmade={'Y' if a else 'N'} cna={'Y' if b else 'N'}\")\n        if diffs:\n            out.append(f\"{aid}/{name}: {'; '.join(diffs)}\")\n    return out\n\n\n# --- assembly ---------------------------------------------------------------\n\ndef finish_rows(per_product):\n    rows = []\n    for name in sorted(per_product):\n        e = per_product[name]\n        row = {\"product\": name}\n        exacts = sorted(set(e[\"exacts\"]), key=lambda v: [int(x) for x in v.split(\".\")])\n        ranges = [dict(t) for t in sorted({tuple(sorted(r.items())) for r in e[\"ranges\"]})]\n        if e.get(\"all\") and not exacts and not ranges:\n            pass  # whole-product row: CPE only\n        if exacts:\n            row[\"versions\"] = exacts\n        if ranges:\n            row[\"ranges\"] = ranges\n        if row.get(\"versions\") or row.get(\"ranges\") or e.get(\"all\"):\n            rows.append(row)\n    return rows\n\n\ndef emit_go(supplement, path):\n    \"\"\"Emit the table as a Go literal (supplement_data.go for the cvrf\n    package); supplement.json stays as the tooling-readable byproduct.\"\"\"\n    def q(s):\n        return '\"' + s.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"') + '\"'\n\n    bounds = [(\"ge\", \"GreaterEqual\"), (\"gt\", \"GreaterThan\"),\n              (\"le\", \"LessEqual\"), (\"lt\", \"LessThan\")]\n    with open(path, \"w\") as f:\n        f.write(\"// Code generated by the fortinet-cvrf-supplement generator\"\n                \" (maintainer tooling); DO NOT EDIT by hand.\\n\"\n                \"// See supplement.go for what this table is and how it was\"\n                \" derived.\\n\\npackage cvrf\\n\\n\"\n                \"var supplementTable = map[string][]supplementProduct{\\n\")\n        for aid in sorted(supplement):\n            f.write(f\"\\t{q(aid)}: {{\\n\")\n            for row in supplement[aid]:\n                parts = [f\"Product: {q(row['product'])}\"]\n                if row.get(\"versions\"):\n                    parts.append(\"Versions: []string{\" +\n                                 \", \".join(q(v) for v in row[\"versions\"]) + \"}\")\n                if row.get(\"ranges\"):\n                    rs = []\n                    for r in row[\"ranges\"]:\n                        rs.append(\"{\" + \", \".join(\n                            f\"{go}: {q(r[k])}\" for k, go in bounds if r.get(k)) + \"}\")\n                    parts.append(\"Ranges: []supplementRange{\" + \", \".join(rs) + \"}\")\n                f.write(\"\\t\\t{\" + \", \".join(parts) + \"},\\n\")\n            f.write(\"\\t},\\n\")\n        f.write(\"}\\n\")\n\n\ndef main():\n    ap = argparse.ArgumentParser()\n    ap.add_argument(\"--fetch-cves\", action=\"store_true\",\n                    help=\"download cvelistV5 records for the gap CVEs, then exit\")\n    args = ap.parse_args()\n\n    table = load_table()\n    rev = reverse_table(table)\n    hm_paths = load_json_tree(HM)\n    cvrf_paths = load_json_tree(CVRF)\n\n    gap = {}  # aid -&gt; cves\n    for aid, p in sorted(cvrf_paths.items()):\n        doc = json.load(open(p))\n        if cvrf_statuses_empty(doc):\n            gap[aid] = cvrf_cves(doc)\n\n    if args.fetch_cves:\n        os.makedirs(CVELIST, exist_ok=True)\n        cfg = []\n        for cves in gap.values():\n            for cve in cves:\n                dst = os.path.join(CVELIST, cve + \".json\")\n                if os.path.exists(dst):\n                    continue\n                y, n = cve.split(\"-\")[1], int(cve.split(\"-\")[2])\n                url = (f\"https://raw.githubusercontent.com/CVEProject/cvelistV5/\"\n                       f\"main/cves/{y}/{n // 1000}xxx/{cve}.json\")\n                cfg += [f'url = \"{url}\"', f'output = \"{dst}\"']\n        if cfg:\n            subprocess.run([\"curl\", \"-s\", \"--parallel\", \"--parallel-max\", \"16\",\n                            \"--config\", \"-\"], input=\"\\n\".join(cfg),\n                           text=True, check=True)\n        print(f\"cvelist records ready: {len(glob.glob(CVELIST + '/*.json'))}\")\n        return\n\n    problems = []\n    notes = []\n    supplement = {}\n    src_of = {}\n    conflicts = []\n    for aid, cves in gap.items():\n        if aid in MANUAL_OVERRIDES:\n            supplement[aid] = MANUAL_OVERRIDES[aid]\n            src_of[aid] = \"manual\"\n            continue\n        hm_problems = []\n        hm = handmade_rows(aid, hm_paths, rev, table, hm_problems, notes)\n        if aid in HANDMADE_EXCLUDED:\n            hm, hm_problems = [], []\n        cna_problems = []\n        cna = cna_rows(aid, cves, table, cna_problems)\n        # Union at product level: handmade rows win for the products handmade\n        # covers (curated ranges); CNA fills the products handmade misses\n        # (handmade 2018+ often tracks only FortiOS while the CNA record also\n        # lists FortiProxy etc.).\n        hp = {r[\"product\"] for r in hm}\n        if hm and cna:\n            conflicts += version_level_diffs(aid, hm, cna)\n        rows = hm + [r for r in (cna or []) if r[\"product\"] not in hp]\n        rows = [row for r in rows\n                for row in (PRODUCT_ROW_FIXUPS.get((aid, r[\"product\"])) or [r])]\n        if rows:\n            supplement[aid] = rows\n            src_of[aid] = (\"handmade\" if not cna else \"cna\" if not hm else \"handmade+cna\")\n            problems += hm_problems if hm else []\n            problems += cna_problems if cna else []\n            if hm and cna:\n                cp = {r[\"product\"] for r in cna}\n                if not hp &amp; cp:\n                    conflicts.append(\n                        f\"{aid}: DISJOINT handmade={sorted(hp)} cna={sorted(cp)} \u2014 verify against the advisory text\")\n                elif hp != cp:\n                    conflicts.append(\n                        f\"{aid}: products differ handmade={sorted(hp)} cna={sorted(cp)} (union emitted)\")\n        else:\n            problems += hm_problems + cna_problems\n\n    # Validate the assembled table: a lower bound above the upper bound (a\n    # transcription slip like handmade's \"ge 4.4.0, le 4.3.1\") would emit an\n    # unsatisfiable criterion \u2014 a silent detection false negative.\n    def vkey(v):\n        return [int(x) for x in v.split(\".\")]\n    for aid, rows in supplement.items():\n        for r in rows:\n            for rg in r.get(\"ranges\") or []:\n                lo, hi = rg.get(\"ge\") or rg.get(\"gt\"), rg.get(\"le\") or rg.get(\"lt\")\n                if lo and hi and vkey(lo) &gt; vkey(hi):\n                    problems.append(\n                        f\"{aid}: inverted range for {r['product']}: {rg} (lower &gt; upper)\")\n\n    with open(os.path.join(HERE, \"supplement.json\"), \"w\") as f:\n        json.dump(supplement, f, indent=1, sort_keys=True)\n        f.write(\"\\n\")\n\n    emit_go(supplement, os.path.join(HERE, \"supplement_data.go\"))\n\n    by_src = collections.Counter(src_of.values())\n    with open(os.path.join(HERE, \"report.md\"), \"w\") as f:\n        f.write(\"# fortinet-cvrf supplement generation report\\n\\n\")\n        f.write(f\"- statuses-empty CVRF advisories: {len(gap)}\\n\")\n        f.write(f\"- supplemented: {len(supplement)} (handmade {by_src['handmade']},\"\n                f\" cna {by_src['cna']}, handmade+cna {by_src['handmade+cna']},\"\n                f\" manual {by_src['manual']})\\n\")\n        f.write(f\"- reviewed content-only: {len(set(gap) &amp; set(REVIEWED_CONTENT_ONLY))}\\n\")\n        unreviewed = sorted(set(gap) - set(supplement) - set(REVIEWED_CONTENT_ONLY))\n        f.write(f\"- UNREVIEWED (must be empty): {len(unreviewed)}\\n\\n\")\n        f.write(\"## unreviewed advisories\\n\\n\")\n        for aid in unreviewed:\n            f.write(f\"- {aid} (CVEs: {', '.join(gap[aid]) or 'none'})\\n\")\n        f.write(\"\\n## reviewed content-only advisories\\n\\n\")\n        for aid in sorted(set(gap) &amp; set(REVIEWED_CONTENT_ONLY)):\n            f.write(f\"- {aid}: {REVIEWED_CONTENT_ONLY[aid]}\\n\")\n        f.write(\"\\n## handmade vs CNA product-set diffs\\n\\n\")\n        for c in conflicts:\n            f.write(f\"- {c}\\n\")\n        f.write(\"\\n## problems\\n\\n\")\n        for p in sorted(set(problems)):\n            f.write(f\"- {p}\\n\")\n        f.write(\"\\n## notes (deliberate skips)\\n\\n\")\n        for p in sorted(set(notes)):\n            f.write(f\"- {p}\\n\")\n\n    print(f\"gap={len(gap)} supplemented={len(supplement)} \"\n          f\"(hm={by_src['handmade']} cna={by_src['cna']}) \"\n          f\"problems={len(set(problems))} conflicts={len(conflicts)}\")\n    print(f\"wrote {os.path.join(HERE, 'supplement.json')} and report.md\")\n\n\nif __name__ == \"__main__\":\n    main()\n", "creation_timestamp": "2026-07-22T09:15:43.950541Z"}]}