feat(extract/fortinet/cvrf): supplement detection for historical advisories without product_statuses - #892
feat(extract/fortinet/cvrf): supplement detection for historical advisories without product_statuses#892shino wants to merge 9 commits into
Conversation
…sories without product_statuses CVRF advisories from 2012 through 2022 (465 of 1,119 files) carry no product_statuses/product_tree upstream, so extraction yielded content without detection. Embed a frozen supplement table (425 advisories, 680 product rows) generated from two Fortinet-authored sources — the curated version ranges of the legacy vuls-data-raw-fortinet (handmade) dataset and the affected-version data of Fortinet's CNA records in cvelistV5 — and fall back to it when product_statuses is empty. The remaining 40 advisories were reviewed and stay content-only (third-party CVEs, hardware-model-only listings, informational advisories). The gap is historical, so the table is a frozen asset, not a maintained feed. Also normalize vulnerability cve[] entries to bare CVE IDs (FG-IR-14-010 suffixes free text and embeds a stray HTML tag), and add the product-table entries the supplement rows and the new FortiSIEMWindowsAgent product tree branch of FG-IR-26-155 require. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR enhances the extract fortinet-cvrf pipeline to restore CPE detection for historical Fortinet CVRF advisories (2012–2022) where upstream product_statuses / product_tree are empty, by falling back to an embedded, frozen supplement table. It also normalizes messy cve[] entries into proper CVE IDs and expands the Fortinet product whitelist/range-type vocabulary to support additional historical products.
Changes:
- Add embedded
supplement.json+ builder (supplement.go) and wire CVRF extraction to use it only whenproduct_statuses.status.typeis empty. - Normalize CVRF
vulnerability.cve[]entries to bare CVE IDs via regex extraction (handling the known<br />artifact). - Extend Fortinet product whitelist and Fortinet per-product range types; update/add golden + fixture coverage and add a whole-table validation test.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/extract/types/data/detection/condition/criteria/criterion/cpecriterion/range/range.go | Adds new Fortinet per-product RangeType constants + JSON/string mappings + Compare dispatch entries. |
| pkg/extract/fortinet/internal/product/table.go | Extends the Fortinet product-name → (CPE, RangeType) whitelist to include additional historical products. |
| pkg/extract/fortinet/cvrf/supplement.go | Implements embedded supplement-table loading and conversion into detection criterions (exact versions / ranges / whole-product). |
| pkg/extract/fortinet/cvrf/supplement.json | Frozen generated advisory→affected-product table used to supplement historical statuses-empty CVRF advisories. |
| pkg/extract/fortinet/cvrf/cvrf.go | Falls back to supplement detection when product_statuses type is empty; normalizes CVE IDs using cvePattern. |
| pkg/extract/fortinet/cvrf/export_test.go | Exposes supplement helpers to _test package for table-wide validation. |
| pkg/extract/fortinet/cvrf/cvrf_test.go | Adds TestSupplementCriterions to ensure every supplement entry builds cleanly and unknown IDs return nil. |
| pkg/extract/fortinet/cvrf/testdata/golden/data/2012/FG-IR-012-001.json | New/updated golden to cover supplemented range-shaped detection output. |
| pkg/extract/fortinet/cvrf/testdata/golden/data/2012/FG-IR-012-003.json | Updated golden to reflect newly supplemented detection output. |
| pkg/extract/fortinet/cvrf/testdata/golden/data/2013/FG-IR-13-008.json | Updated golden to reflect newly supplemented detection output (mixed exact/range/whole-product cases). |
| pkg/extract/fortinet/cvrf/testdata/golden/data/2014/FG-IR-14-010.json | New golden validating whole-product detection + CVE ID normalization from messy cve[] strings. |
| pkg/extract/fortinet/cvrf/testdata/golden/data/2021/FG-IR-21-085.json | New golden validating supplemented enumeration-shaped detection output. |
| pkg/extract/fortinet/cvrf/testdata/fixtures/2012/FG-IR-012-001.json | Adds fixture exercising statuses-empty advisory structure. |
| pkg/extract/fortinet/cvrf/testdata/fixtures/2014/FG-IR-14-010.json | Adds fixture containing the known malformed CVE strings (free text + <br />). |
| pkg/extract/fortinet/cvrf/testdata/fixtures/2021/FG-IR-21-085.json | Adds fixture for a statuses-empty advisory that gains supplemented detection. |
…y exact string instead of lenient pattern A pattern that recovers "the" CVE ID from arbitrary junk would silently mis-handle a future malformed shape (e.g. keep only the first of two IDs in one entry). Enumerate the three known FG-IR-14-010 entries as exact- string fixups and hard-error on any other non-bare entry, matching the whitelist policy used elsewhere in the extractor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…teral instead of embedded JSON Match the repo's established pattern for compiled-in frozen data (microsoft/bulletin's archive amendments, microsoft/cvrf's per-CVE overrides — pkg/extract has no other go:embed). A Go literal is also checked at compile time, whereas a misspelled key in the embedded JSON would be silently ignored by unmarshal and degrade that row to a whole-product match. Extraction output is byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in cve entry validation Align with the nvd/feed/cve/v2 cveIDPattern; \d+ would accept 1-3 digit sequences no real CVE ID has. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…instead of bare build 2312 FortiClient SSLVPN versions as "major.minor.build" over one monotonic build sequence, but this advisory (and the handmade row) names only the bare build: "build 2312 and lower", fixed in 2313. The bare bound numerically swallows every full-form version (4 < 2312), including the not-affected 4.0.2328 of FG-IR-16-048. The 4.0 prefix for build 2312 is attested on both sides — 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 starts at 4.4.2334) — so the bound becomes le 4.0.2312. No other bare (dot-less) token remains in the supplement table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
pkg/extract/fortinet/cvrf/supplement.go:109
- supplementCriterions currently validates that bounds are parseable, but it does not validate that a constructed range is non-contradictory (e.g. GreaterEqual > LessEqual, or equal bounds with an exclusive end). This allows mistakes in the frozen supplement table to silently produce empty ranges and therefore silently drop detection.
for _, sr := range row.Ranges {
r := ccRangeTypes.Range{
Type: rt,
GreaterEqual: sr.GreaterEqual,
GreaterThan: sr.GreaterThan,
LessEqual: sr.LessEqual,
LessThan: sr.LessThan,
}
for _, b := range []string{r.GreaterEqual, r.GreaterThan, r.LessEqual, r.LessThan} {
if b == "" {
continue
}
if _, err := numericVersion.NewVersion(b); err != nil {
return nil, errors.Wrapf(err, "unexpected range bound %q for %q in supplement entry %q", b, row.Product, id)
}
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
pkg/extract/fortinet/cvrf/supplement.go:106
- supplementCriterions validates that range bounds parse as numeric, but it doesn’t validate that the bounds form a sensible range (e.g., lower <= upper) or that mutually-exclusive fields (ge+gt, le+lt) aren’t both set. Since the frozen supplement table already contains inverted ranges, adding these checks would make bad table edits fail loudly and prevent silent false negatives.
for _, b := range []string{r.GreaterEqual, r.GreaterThan, r.LessEqual, r.LessThan} {
if b == "" {
continue
}
if _, err := numericVersion.NewVersion(b); err != nil {
…ject them at build time Two handmade-derived rows carried transposed bounds that made their criterions unsatisfiable: FG-IR-22-056's FortiDeceptor 3.0/3.1 rows (ge 3.0.2 le 3.0.0, ge 3.1.1 le 3.1.0 — the advisory note and the CNA record both read 3.0.0-3.0.2 / 3.1.0-3.1.1, and the note lists 3.3.3 as affected where handmade marked it fixed; the advisory now uses the CNA rows, which also add FortiSandbox ranges handmade lacked) and FG-IR-22-059's FortiWAN row (ge 4.4.0 le 4.3.1 — a corrupted "4.3 all versions"; 4.3/4.4 are modeled as trains per the note). supplementCriterions now rejects any range whose lower bound exceeds its upper bound, so the whole-table test catches this class of table error instead of shipping never-matching criterions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
pkg/extract/fortinet/cvrf/supplement.go:106
- supplementCriterions validates that bounds parse as numeric versions, but it doesn’t validate that each range is logically satisfiable (lower bound <= upper bound). The current supplement table already contains inverted bounds, which will silently produce criterions that never match. Consider adding an explicit lower/upper ordering check here and failing extraction when a table entry is inconsistent.
LessThan: sr.LessThan,
}
for _, b := range []string{r.GreaterEqual, r.GreaterThan, r.LessEqual, r.LessThan} {
if b == "" {
continue
…the advisory notes A full version-level cross-check of every advisory covered by both the legacy handmade dataset and the Fortinet CNA records (evaluating each version token either source mentions against both sources' affected predicates, then arbitrating every disagreement against the advisory's Affected Products note) surfaced eight entries where the emitted handmade rows contradicted the note: - FG-IR-19-186: handmade carried 5.0.3-5.0.5, unrelated to this advisory (the CVE reads "6.2.1, 6.2.0, 6.0.8 and below") — CNA rows - FG-IR-17-245: "5.2 and below" was cut off at ge 5.2.0 - FG-IR-19-034: "5.2.0 to 5.6.10" was cut off at ge 5.6.0 - FG-IR-20-082 / FG-IR-20-103: "6.0.10 and below" written as le 6.0.11, pulling the fixed release into the range - FG-IR-20-158: 6.0 train stopped at 6.0.13 where the note says 6.0.x (CNA enumerates through 6.0.14) - FG-IR-18-108: no versions in the note; CNA extends le 6.0.4 to 6.0.5 - FG-IR-18-232: "at least" note; CNA adds 5.6.0-5.6.11 for FortiAnalyzer/FortiManager The other twenty-nine disagreements were arbitrated in handmade's favor (CNA coarser trains/enumerations, boundary-token artifacts, or CNA contradicting the note) and left as-is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
Adds an embedded supplement table to
extract fortinet-cvrfso the historical CVRF advisories (2012–2022) whoseproduct_statuses/product_treeare empty upstream still produce detection. 425 of the 465 statuses-empty advisories gain detection (680 product rows); with this, 1,068 of the 1,119 corpus files carry detection (previously 643).Why
Fortinet only started populating
product_statusesconsistently in 2022, and CSAF only exists for recent advisories (effectively 2024+), so the 2012–2022 corpus extracted as content-only — a detection gap for exactly the advisories the retired handmade dataset used to cover.How
pkg/extract/fortinet/cvrf/supplement_data.go— a frozen, generated Go-literal table (same pattern as microsoft/bulletin's compiled-in archive amendments) keyed by advisory ID: per product either exactversions,ranges, or neither (= whole product). Generated once from two Fortinet-authored sources and then hand-reviewed:vuls-data-raw-fortinet(handmade) dataset — precise for 2012–2018, but its 2019+ entries are mostly constraint-less and sometimes list not-impacted products, so only version-constrained nodes are used;containers.cna.affected,assignerShortName: fortinetonly) — covers 2016–2022, exactly where handmade degrades.supplement.go— builds criterions from the table in the same shapes the existing paths emit (exact versions →CPEMatcheslike the Known Affected path; ranges → per-product range type like the CSAF extractor), with the same whitelist + hard-error policy.cvrf.gofalls back to it only whenproduct_statusesis empty.internal/productwhitelist (no CPEs are taken from cvelistV5), so the CPE vocabulary is identical to the CSAF/CVRF paths. 14 historical products are added to the table (CPE slugs from the legacy handmade curation) with per-product range types.cve[]entries to bare CVE IDs — FG-IR-14-010 suffixes free text and embeds a stray HTML tag (CVE-<br />2014-2722 key issue), which previously became the vulnerability ID verbatim.FortiSIEMWindowsAgent(product tree branch of the new FG-IR-26-155) to the whitelist, which the current raw corpus requires to extract at all.The gap is historical and closed going forward (2023+ statuses-empty files are third-party/informational only), so the table is a frozen asset, not a maintained feed.
Testing
GOEXPERIMENT=jsonv2 go test ./...passes.TestSupplementCriterionswalks every embedded table entry and asserts it builds cleanly against the product table and numeric version scheme.extract fortinet-cvrfover the currentvuls-data-raw-fortinet-cvrf(1,119 files) completes without error.The table generator (inputs fetch + generation + cross-source arbitration log) is published as a gist: https://gist.github.com/shino/5cefdef6d36283f50795e32ffcf1a51d
🤖 Generated with Claude Code