From 63f11392c777724351d2e081ca056dc73c18c63b Mon Sep 17 00:00:00 2001 From: Shunichi Shinohara Date: Thu, 25 Jun 2026 13:01:44 +0900 Subject: [PATCH 1/3] refactor(detector/vuls2): unify CveContent construction in toCveContent (output-neutral) Introduce a single CveContent builder, toCveContent(cveContentInput, optional), fed by a source-neutral projection. Two adapters (fromVulnContent, fromDistroAdvisory) plus flattenCWE/orYear1000 absorb the CWE-flatten and year-1000 Published/Modified closures that were copy-pasted across the NVD detection path, the advisory-only fallback, enrichNVD, and enrichRedHatCVE. CVSS is supplied already-computed by the caller (toCvss for detection, enrichCvss for enrich) and never recomputed in the builder, so the refactor is output-neutral. provenance is the builder's second arg (detection passes vuls2-sources, enrich passes nil). cveContentSourceLink now takes the CVE-ID string instead of the vulnerability struct. No behavior change: existing NVD detection/enrich golden tests pass unchanged. Co-Authored-By: Claude Opus 4.8 --- detector/vuls2/vendor.go | 230 +++++++++++++++++++++++---------------- detector/vuls2/vuls2.go | 61 ++--------- 2 files changed, 145 insertions(+), 146 deletions(-) diff --git a/detector/vuls2/vendor.go b/detector/vuls2/vendor.go index fa385c9c4b..4b7ca3e82d 100644 --- a/detector/vuls2/vendor.go +++ b/detector/vuls2/vendor.go @@ -3,6 +3,7 @@ package vuls2 import ( "cmp" "fmt" + "net/url" "slices" "strings" "time" @@ -14,6 +15,7 @@ import ( dataTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data" advisoryTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/advisory" + cweTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/cwe" criterionTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion" noneexistcriterionTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/noneexistcriterion" vcAffectedRangeTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/versioncriterion/affected/range" @@ -631,31 +633,151 @@ func cveContentOptional(e ecosystemTypes.Ecosystem, v vulnerabilityTypes.Vulnera return m } -func cveContentSourceLink(ccType models.CveContentType, v vulnerabilityTypes.Vulnerability) string { +func cveContentSourceLink(ccType models.CveContentType, cveID string) string { switch ccType { case models.RedHat, models.RedHatAPI: - return fmt.Sprintf("https://access.redhat.com/security/cve/%s", v.Content.ID) + return fmt.Sprintf("https://access.redhat.com/security/cve/%s", cveID) case models.Oracle: - return fmt.Sprintf("https://linux.oracle.com/cve/%s.html", v.Content.ID) + return fmt.Sprintf("https://linux.oracle.com/cve/%s.html", cveID) case models.Amazon: - return fmt.Sprintf("https://explore.alas.aws.amazon.com/%s.html", v.Content.ID) + return fmt.Sprintf("https://explore.alas.aws.amazon.com/%s.html", cveID) case models.SUSE: - return fmt.Sprintf("https://www.suse.com/security/cve/%s.html", v.Content.ID) + return fmt.Sprintf("https://www.suse.com/security/cve/%s.html", cveID) case models.Debian, models.DebianSecurityTracker: - return fmt.Sprintf("https://security-tracker.debian.org/tracker/%s", v.Content.ID) + return fmt.Sprintf("https://security-tracker.debian.org/tracker/%s", cveID) case models.Ubuntu, models.UbuntuAPI: - return fmt.Sprintf("https://ubuntu.com/security/%s", v.Content.ID) + return fmt.Sprintf("https://ubuntu.com/security/%s", cveID) case models.Alpine: - return fmt.Sprintf("https://security.alpinelinux.org/vuln/%s", v.Content.ID) + return fmt.Sprintf("https://security.alpinelinux.org/vuln/%s", cveID) case models.Nvd: - return fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", v.Content.ID) + return fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", cveID) case models.Microsoft: - return fmt.Sprintf("https://msrc.microsoft.com/update-guide/vulnerability/%s", v.Content.ID) + return fmt.Sprintf("https://msrc.microsoft.com/update-guide/vulnerability/%s", cveID) default: return "" } } +// cveContentInput is the source-neutral projection that both vulnerability and +// advisory content map into. It carries exactly what models.CveContent needs so +// that toCveContent is the single place a CveContent is built (detection and +// enrich). CVSS is supplied already-computed (toCvss for detection, enrichCvss +// for enrich, zero for Cisco) — never recomputed in the builder — so the builder +// stays output-neutral across paths. References are raw URLs mapped via +// toReference inside the builder; RefTag/RefHost optionally re-tag a reference's +// Source when its host matches (e.g. "CISCO" for *.cisco.com). +type cveContentInput struct { + Type models.CveContentType + CveID string + Title, Summary string + SourceLink string + RefURLs []string + CweIDs []string + Cvss2 v2.CVSSv2 + Cvss3 v31.CVSSv31 + Cvss40 v40.CVSSv40 + Published, Modified *time.Time + RefTag, RefHost string +} + +// toCveContent is the single models.CveContent constructor for vuls2 data. +// optional carries provenance: the detection path passes the marshaled +// []source under "vuls2-sources"; the enrich path passes nil. +func toCveContent(in cveContentInput, optional map[string]string) models.CveContent { + var rs models.References + for _, u := range in.RefURLs { + ref := toReference(u) + if in.RefTag != "" { + if parsed, err := url.Parse(u); err == nil { + if h := parsed.Hostname(); h == in.RefHost || strings.HasSuffix(h, "."+in.RefHost) { + ref.Source = in.RefTag + } + } + } + rs = append(rs, ref) + } + + return models.CveContent{ + Type: in.Type, + CveID: in.CveID, + Title: in.Title, + Summary: in.Summary, + Cvss2Score: in.Cvss2.BaseScore, + Cvss2Vector: in.Cvss2.Vector, + Cvss2Severity: in.Cvss2.NVDBaseSeverity, + Cvss3Score: in.Cvss3.BaseScore, + Cvss3Vector: in.Cvss3.Vector, + Cvss3Severity: in.Cvss3.BaseSeverity, + Cvss40Score: in.Cvss40.Score, + Cvss40Vector: in.Cvss40.Vector, + Cvss40Severity: in.Cvss40.Severity, + SourceLink: in.SourceLink, + References: rs, + CweIDs: in.CweIDs, + Published: orYear1000(in.Published), + LastModified: orYear1000(in.Modified), + Optional: optional, + } +} + +// flattenCWE flattens the per-entry CWE id lists into one slice (nil when empty). +func flattenCWE(cwes []cweTypes.CWE) []string { + var ids []string //nolint:prealloc + for _, c := range cwes { + ids = append(ids, c.CWE...) + } + return ids +} + +// orYear1000 dereferences t, or returns the year-1000 sentinel used across vuls2 +// for a missing timestamp. +func orYear1000(t *time.Time) time.Time { + if t != nil { + return *t + } + return time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC) +} + +// fromVulnContent projects a vulnerability stub into the neutral input. Used by +// every vulnerability-sourced path (NVD/RedHat detection and enrich). The CVSS +// triplet is supplied by the caller so the detection (toCvss) and enrich +// (enrichCvss) variants stay faithful. +func fromVulnContent(cctype models.CveContentType, v vulnerabilityTypes.Vulnerability, cvss2 v2.CVSSv2, cvss3 v31.CVSSv31, cvss40 v40.CVSSv40) cveContentInput { + refURLs := make([]string, 0, len(v.Content.References)) + for _, r := range v.Content.References { + refURLs = append(refURLs, r.URL) + } + return cveContentInput{ + Type: cctype, + CveID: string(v.Content.ID), + Title: v.Content.Title, + Summary: v.Content.Description, + SourceLink: cveContentSourceLink(cctype, string(v.Content.ID)), + RefURLs: refURLs, + CweIDs: flattenCWE(v.Content.CWE), + Cvss2: cvss2, + Cvss3: cvss3, + Cvss40: cvss40, + Published: v.Content.Published, + Modified: v.Content.Modified, + } +} + +// fromDistroAdvisory projects a DistroAdvisory (the advisory-only fallback, where +// no Content object exists) into the neutral input. The single advisory +// reference ar is attached by the caller after building. +func fromDistroAdvisory(cctype models.CveContentType, da models.DistroAdvisory, ar models.Reference) cveContentInput { + issued, updated := da.Issued, da.Updated + return cveContentInput{ + Type: cctype, + CveID: da.AdvisoryID, + Summary: da.Description, + SourceLink: ar.Link, + Published: &issued, + Modified: &updated, + } +} + func compareSource(a, b source) int { preferenceFn := func(e ecosystemTypes.Ecosystem) int { et, _, _ := strings.Cut(string(e), ":") @@ -1221,12 +1343,7 @@ func enrichRedHatCVE(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnera for _, v := range vulns { cvss2, cvss3, cvss40 := enrichCvss(v.Content.Severity) - var rs models.References - for _, r := range v.Content.References { - rs = append(rs, toReference(r.URL)) - } - - sourceLink := cveContentSourceLink(models.RedHatAPI, v) + sourceLink := cveContentSourceLink(models.RedHatAPI, string(v.Content.ID)) for _, m := range v.Content.Mitigations { if m.Description == "" { continue @@ -1238,42 +1355,8 @@ func enrichRedHatCVE(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnera }) } - vi.CveContents[models.RedHatAPI] = append(vi.CveContents[models.RedHatAPI], models.CveContent{ - Type: models.RedHatAPI, - CveID: string(v.Content.ID), - Title: v.Content.Title, - Summary: v.Content.Description, - Cvss2Score: cvss2.BaseScore, - Cvss2Vector: cvss2.Vector, - Cvss2Severity: cvss2.NVDBaseSeverity, - Cvss3Score: cvss3.BaseScore, - Cvss3Vector: cvss3.Vector, - Cvss3Severity: cvss3.BaseSeverity, - Cvss40Score: cvss40.Score, - Cvss40Vector: cvss40.Vector, - Cvss40Severity: cvss40.Severity, - SourceLink: sourceLink, - References: rs, - CweIDs: func() []string { - var cs []string //nolint:prealloc - for _, cwe := range v.Content.CWE { - cs = append(cs, cwe.CWE...) - } - return cs - }(), - Published: func() time.Time { - if v.Content.Published != nil { - return *v.Content.Published - } - return time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC) - }(), - LastModified: func() time.Time { - if v.Content.Modified != nil { - return *v.Content.Modified - } - return time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC) - }(), - }) + vi.CveContents[models.RedHatAPI] = append(vi.CveContents[models.RedHatAPI], + toCveContent(fromVulnContent(models.RedHatAPI, v, cvss2, cvss3, cvss40), nil)) } } } @@ -1314,11 +1397,6 @@ func enrichNVD(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnerability cvss2, cvss3, cvss40 := enrichCvss(v.Content.Severity) - var rs models.References - for _, r := range v.Content.References { - rs = append(rs, toReference(r.URL)) - } - for _, e := range v.Content.Exploit { if e.Link == "" { continue @@ -1338,42 +1416,8 @@ func enrichNVD(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnerability }) } - vi.CveContents[models.Nvd] = append(vi.CveContents[models.Nvd], models.CveContent{ - Type: models.Nvd, - CveID: string(v.Content.ID), - Title: v.Content.Title, - Summary: v.Content.Description, - Cvss2Score: cvss2.BaseScore, - Cvss2Vector: cvss2.Vector, - Cvss2Severity: cvss2.NVDBaseSeverity, - Cvss3Score: cvss3.BaseScore, - Cvss3Vector: cvss3.Vector, - Cvss3Severity: cvss3.BaseSeverity, - Cvss40Score: cvss40.Score, - Cvss40Vector: cvss40.Vector, - Cvss40Severity: cvss40.Severity, - SourceLink: cveContentSourceLink(models.Nvd, v), - References: rs, - CweIDs: func() []string { - var cs []string //nolint:prealloc - for _, cwe := range v.Content.CWE { - cs = append(cs, cwe.CWE...) - } - return cs - }(), - Published: func() time.Time { - if v.Content.Published != nil { - return *v.Content.Published - } - return time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC) - }(), - LastModified: func() time.Time { - if v.Content.Modified != nil { - return *v.Content.Modified - } - return time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC) - }(), - }) + vi.CveContents[models.Nvd] = append(vi.CveContents[models.Nvd], + toCveContent(fromVulnContent(models.Nvd, v, cvss2, cvss3, cvss40), nil)) } } } diff --git a/detector/vuls2/vuls2.go b/detector/vuls2/vuls2.go index 262c4603e7..44289f6d3d 100644 --- a/detector/vuls2/vuls2.go +++ b/detector/vuls2/vuls2.go @@ -1269,19 +1269,17 @@ func walkVulnerabilityDatas(m map[source]sourceData, vds []detectTypes.Vulnerabi cctype := toCveContentType(src.Segment.Ecosystem, sid) cvss2, cvss3, cvss40 := toCvss(src.Segment.Ecosystem, sid, v.Content.Severity) - var rs models.References - for _, r := range v.Content.References { - rs = append(rs, toReference(r.URL)) - } + cc := toCveContent(fromVulnContent(cctype, v, cvss2, cvss3, cvss40), + cveContentOptional(src.Segment.Ecosystem, v, string(bs))) for _, da := range fdas { ar, err := advisoryReference(src.Segment.Ecosystem, src.SourceID, da) if err != nil { return models.VulnInfo{}, xerrors.Errorf("Failed to get advisory reference. err: %w", err) } - if !slices.ContainsFunc(rs, func(r models.Reference) bool { + if !slices.ContainsFunc(cc.References, func(r models.Reference) bool { return r.Link == ar.Link && r.Source == ar.Source && r.RefID == ar.RefID && slices.Equal(r.Tags, ar.Tags) }) { - rs = append(rs, ar) + cc.References = append(cc.References, ar) } } @@ -1327,43 +1325,7 @@ func walkVulnerabilityDatas(m map[source]sourceData, vds []detectTypes.Vulnerabi DistroAdvisories: fdas, Exploits: exploits, Mitigations: mitigations, - CveContents: models.NewCveContents(models.CveContent{ - Type: cctype, - CveID: string(v.Content.ID), - Title: v.Content.Title, - Summary: v.Content.Description, - Cvss2Score: cvss2.BaseScore, - Cvss2Vector: cvss2.Vector, - Cvss2Severity: cvss2.NVDBaseSeverity, - Cvss3Score: cvss3.BaseScore, - Cvss3Vector: cvss3.Vector, - Cvss3Severity: cvss3.BaseSeverity, - Cvss40Score: cvss40.Score, - Cvss40Vector: cvss40.Vector, - Cvss40Severity: cvss40.Severity, - SourceLink: cveContentSourceLink(cctype, v), - References: rs, - CweIDs: func() []string { - var cs []string - for _, cwe := range v.Content.CWE { - cs = append(cs, cwe.CWE...) - } - return cs - }(), - Published: func() time.Time { - if v.Content.Published != nil { - return *v.Content.Published - } - return time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC) - }(), - LastModified: func() time.Time { - if v.Content.Modified != nil { - return *v.Content.Modified - } - return time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC) - }(), - Optional: cveContentOptional(src.Segment.Ecosystem, v, string(bs)), - }), + CveContents: models.NewCveContents(cc), }, nil }() if err != nil { @@ -1409,20 +1371,13 @@ func walkVulnerabilityDatas(m map[source]sourceData, vds []detectTypes.Vulnerabi return xerrors.Errorf("Failed to get advisory reference. err: %w", err) } + cc := toCveContent(fromDistroAdvisory(cctype, da, ar), map[string]string{"vuls2-sources": string(bs)}) + cc.References = models.References{ar} vinfo := models.VulnInfo{ CveID: da.AdvisoryID, Confidences: models.Confidences{toVuls0Confidence(src.Segment.Ecosystem, src.SourceID, m[src])}, DistroAdvisories: models.DistroAdvisories{da}, - CveContents: models.NewCveContents(models.CveContent{ - Type: cctype, - CveID: da.AdvisoryID, - Summary: da.Description, - SourceLink: ar.Link, - References: models.References{ar}, - Published: da.Issued, - LastModified: da.Updated, - Optional: map[string]string{"vuls2-sources": string(bs)}, - }), + CveContents: models.NewCveContents(cc), } base := m[src] From c8cf83fb8c899d550161b8e11ed5863919f4ff5d Mon Sep 17 00:00:00 2001 From: Shunichi Shinohara Date: Thu, 25 Jun 2026 13:06:34 +0900 Subject: [PATCH 2/3] refactor(detector/vuls2): route detection CveContent by content origin (output-neutral) Add contentOrigin + sourcePolicy + policyFor, and an advByID index built uniformly in the advisory loop. The vulnerability loop now selects the content source with a single switch on policyFor(sid).origin: originAdvisory builds one CveContent per advisory (via the new fromAdvContent adapter), originVulnerability keeps the stub-plus-advisory-refs behavior. No source is advisory-sourced yet (policyFor returns originVulnerability for all), so the originAdvisory branch is dead and behavior is unchanged: NVD detection/enrich goldens pass. This is the structural seam Cisco plugs into. Co-Authored-By: Claude Opus 4.8 --- detector/vuls2/vendor.go | 58 ++++++++++++++++++++++++++++++++++++++++ detector/vuls2/vuls2.go | 46 +++++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/detector/vuls2/vendor.go b/detector/vuls2/vendor.go index 4b7ca3e82d..aa86a5490c 100644 --- a/detector/vuls2/vendor.go +++ b/detector/vuls2/vendor.go @@ -15,6 +15,7 @@ import ( dataTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data" advisoryTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/advisory" + advisoryContentTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/advisory/content" cweTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/cwe" criterionTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion" noneexistcriterionTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/noneexistcriterion" @@ -778,6 +779,63 @@ func fromDistroAdvisory(cctype models.CveContentType, da models.DistroAdvisory, } } +// contentOrigin says which content feeds a source's authoritative CveContent. +type contentOrigin int + +const ( + // originVulnerability: the vulnerability stub is the content (NVD, RedHat, …). + originVulnerability contentOrigin = iota + // originAdvisory: the advisory carries the rich content; the vulnerability is + // a thin CVE stub (cisco-json, and future advisory-sourced PSIRT feeds). + originAdvisory +) + +// sourcePolicy holds the per-source presentation that varies between CPE +// sources. It is keyed by SourceID (not CveContentType): the same type can be +// vulnerability-sourced from one source and advisory-sourced from another +// (e.g. cisco-csaf/cisco-cvrf vs cisco-json, all models.Cisco). Adding a source +// is one case in policyFor; the rest of the pipeline consults it generically. +type sourcePolicy struct { + origin contentOrigin + refTag, refHost string // reference Source override by host (e.g. CISCO / cisco.com) + advisorySourceLink func(advisoryID string) string // nil for vulnerability-sourced +} + +// policyFor returns the routing policy for a source. Sources not listed are +// vulnerability-sourced with no presentation overrides (the existing default). +func policyFor(_ sourceTypes.SourceID) sourcePolicy { + return sourcePolicy{origin: originVulnerability} +} + +// fromAdvContent projects an advisory's content into the neutral input. The +// CVE-ID is not in the advisory content — it comes from the paired vulnerability +// stub — so the caller supplies it. Presentation (SourceLink, ref tagging) comes +// from the policy; CVSS is left zero (advisory-sourced sources such as Cisco +// carry no CVSS — the vendor severity is surfaced via DistroAdvisory.Severity). +func fromAdvContent(cctype models.CveContentType, cveID string, c advisoryContentTypes.Content, p sourcePolicy) cveContentInput { + refURLs := make([]string, 0, len(c.References)) + for _, r := range c.References { + refURLs = append(refURLs, r.URL) + } + var sourceLink string + if p.advisorySourceLink != nil { + sourceLink = p.advisorySourceLink(string(c.ID)) + } + return cveContentInput{ + Type: cctype, + CveID: cveID, + Title: c.Title, + Summary: c.Description, + SourceLink: sourceLink, + RefURLs: refURLs, + CweIDs: flattenCWE(c.CWE), + Published: c.Published, + Modified: c.Modified, + RefTag: p.refTag, + RefHost: p.refHost, + } +} + func compareSource(a, b source) int { preferenceFn := func(e ecosystemTypes.Ecosystem) int { et, _, _ := strings.Cut(string(e), ":") diff --git a/detector/vuls2/vuls2.go b/detector/vuls2/vuls2.go index 44289f6d3d..e631466793 100644 --- a/detector/vuls2/vuls2.go +++ b/detector/vuls2/vuls2.go @@ -17,6 +17,7 @@ import ( "golang.org/x/xerrors" dataTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data" + advisoryContentTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/advisory/content" criteriaTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria" criterionTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion" vcAffectedRangeTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/versioncriterion/affected/range" @@ -1188,6 +1189,10 @@ func walkPkgCriteria(e ecosystemTypes.Ecosystem, sourceID sourceTypes.SourceID, func walkVulnerabilityDatas(m map[source]sourceData, vds []detectTypes.VulnerabilityData) error { for _, vd := range vds { am := make(map[source]models.DistroAdvisories) + // advByID indexes this root's advisory contents per source, built + // uniformly here and consumed by the vulnerability loop only when the + // source is advisory-sourced (policyFor(...).origin == originAdvisory). + advByID := make(map[source][]advisoryContentTypes.Content) for _, vda := range vd.Advisories { for sid, rm := range vda.Contents { if rm == nil { @@ -1229,6 +1234,7 @@ func walkVulnerabilityDatas(m map[source]sourceData, vds []detectTypes.Vulnerabi }(), Description: a.Content.Description, }) + advByID[src] = append(advByID[src], a.Content) } } } @@ -1269,18 +1275,36 @@ func walkVulnerabilityDatas(m map[source]sourceData, vds []detectTypes.Vulnerabi cctype := toCveContentType(src.Segment.Ecosystem, sid) cvss2, cvss3, cvss40 := toCvss(src.Segment.Ecosystem, sid, v.Content.Severity) - cc := toCveContent(fromVulnContent(cctype, v, cvss2, cvss3, cvss40), - cveContentOptional(src.Segment.Ecosystem, v, string(bs))) - for _, da := range fdas { - ar, err := advisoryReference(src.Segment.Ecosystem, src.SourceID, da) - if err != nil { - return models.VulnInfo{}, xerrors.Errorf("Failed to get advisory reference. err: %w", err) + optional := cveContentOptional(src.Segment.Ecosystem, v, string(bs)) + policy := policyFor(sid) + var ccs models.CveContents + switch policy.origin { + case originAdvisory: + // Advisory-sourced (e.g. cisco-json): the rich content lives + // in the advisory the root carries for this source, not the + // vulnerability stub. Build one CveContent per advisory, + // keyed by the CVE-ID from the stub. + ccs = models.CveContents{} + for _, c := range advByID[src] { + cc := toCveContent(fromAdvContent(cctype, string(v.Content.ID), c, policy), optional) + ccs[cc.Type] = append(ccs[cc.Type], cc) } - if !slices.ContainsFunc(cc.References, func(r models.Reference) bool { - return r.Link == ar.Link && r.Source == ar.Source && r.RefID == ar.RefID && slices.Equal(r.Tags, ar.Tags) - }) { - cc.References = append(cc.References, ar) + default: + // Vulnerability-sourced (NVD etc.): the stub is the content, + // with the source's advisory references merged in. + cc := toCveContent(fromVulnContent(cctype, v, cvss2, cvss3, cvss40), optional) + for _, da := range fdas { + ar, err := advisoryReference(src.Segment.Ecosystem, src.SourceID, da) + if err != nil { + return models.VulnInfo{}, xerrors.Errorf("Failed to get advisory reference. err: %w", err) + } + if !slices.ContainsFunc(cc.References, func(r models.Reference) bool { + return r.Link == ar.Link && r.Source == ar.Source && r.RefID == ar.RefID && slices.Equal(r.Tags, ar.Tags) + }) { + cc.References = append(cc.References, ar) + } } + ccs = models.NewCveContents(cc) } // Map content-level Exploit / Mitigations into the @@ -1325,7 +1349,7 @@ func walkVulnerabilityDatas(m map[source]sourceData, vds []detectTypes.Vulnerabi DistroAdvisories: fdas, Exploits: exploits, Mitigations: mitigations, - CveContents: models.NewCveContents(cc), + CveContents: ccs, }, nil }() if err != nil { From f5b8560362451dd61903cbd998aec5c7d69eddcd Mon Sep 17 00:00:00 2001 From: Shunichi Shinohara Date: Thu, 25 Jun 2026 13:13:08 +0900 Subject: [PATCH 3/3] feat(detector): route Cisco (cisco-json) CPE detection to vuls2 Enable Cisco as the first advisory-sourced CPE source on the unified pipeline: - policyFor(CiscoJSON) returns originAdvisory with the CISCO/cisco.com reference tag and the sec.cloudapps.cisco.com advisory SourceLink, so the detection path builds Cisco CveContent from the advisory (with vuls2-sources provenance) via the shared toCveContent; Cisco carries no CVSS (vendor SIR -> DistroAdvisory). - enrichAdvisories backfills advisory-sourced content (enrichAdvisoryContent) for CVEs detected by other sources, guarded against overwriting detection content and attaching no provenance; cisco-json added to the enrich DataSources. - mergeVulnInfo keys CveContents by (type, source link) so a CVE under multiple Cisco advisories keeps all of them; vulnerability-sourced types are unaffected (their link is deterministic per CVE). - detector.go: Cisco dropped from the go-cve-dictionary admission gate and content fill, and stripped before getMaxConfidence (mirrors NVD), so Cisco is reported solely by vuls2. NVD detection/enrich goldens unchanged; ported Cisco postConvert/enrich tests and the real cisco-json fixture pass. Co-Authored-By: Claude Opus 4.8 --- detector/detector.go | 35 +-- .../2025/cisco-sa-n39k-isis-dos-JhJA8Rfx.json | 219 ++++++++++++++++++ .../enrich/cisco-json/datasource.json | 11 + detector/vuls2/vendor.go | 41 +++- detector/vuls2/vuls2.go | 23 +- detector/vuls2/vuls2_test.go | 186 +++++++++++++++ 6 files changed, 484 insertions(+), 31 deletions(-) create mode 100644 detector/vuls2/testdata/fixtures/enrich/cisco-json/data/2025/cisco-sa-n39k-isis-dos-JhJA8Rfx.json create mode 100644 detector/vuls2/testdata/fixtures/enrich/cisco-json/datasource.json diff --git a/detector/detector.go b/detector/detector.go index 033d098ed4..39b07d5cfd 100644 --- a/detector/detector.go +++ b/detector/detector.go @@ -435,7 +435,6 @@ func FillCvesWithGoCVEDictionary(r *models.ScanResult, cnf config.GoCveDictConf, fortinets := models.ConvertFortinetToModel(d.CveID, d.Fortinets) mitres := models.ConvertMitreToModel(d.CveID, d.Mitres) paloaltos := models.ConvertPaloaltoToModel(d.CveID, d.Paloaltos) - ciscos := models.ConvertCiscoToModel(d.CveID, d.Ciscos) alerts := fillCertAlerts(&d) for cveID, vinfo := range r.ScannedCves { @@ -443,15 +442,14 @@ func FillCvesWithGoCVEDictionary(r *models.ScanResult, cnf config.GoCveDictConf, if vinfo.CveContents == nil { vinfo.CveContents = models.CveContents{} } - // NVD CveContent (and its exploits/mitigations and US-CERT - // alerts) is now provided by the vuls2 detection/enrich path - // (see vuls2.enrichNVD), so go-cve-dictionary no longer fills it - // here. JP-CERT alerts stay here — they come from JVN, which is - // not migrated. + // NVD and Cisco CveContent are now provided by the vuls2 + // detection/enrich path (see vuls2.enrichNVD / enrichAdvisories), + // so go-cve-dictionary no longer fills them here. JP-CERT alerts + // stay here — they come from JVN, which is not migrated. for _, con := range vulnchecks { vinfo.CveContents[con.Type] = append(vinfo.CveContents[con.Type], con) } - for _, cons := range [][]models.CveContent{jvns, euvds, fortinets, paloaltos, ciscos} { + for _, cons := range [][]models.CveContent{jvns, euvds, fortinets, paloaltos} { for _, con := range cons { if !con.Empty() { if !slices.ContainsFunc(vinfo.CveContents[con.Type], func(e models.CveContent) bool { @@ -552,30 +550,18 @@ func detectCpeURIsCvesWithGoCVEDictionary(r *models.ScanResult, cpes []Cpe, cnf for _, detail := range details { // Skip detections carried by no dictionary-remaining DETECTION // source. The list mirrors go-cve-dictionary's GetByCpeURI - // admission gate minus the vuls2-migrated sources (NVD, so - // far), so NVD-only detections disappear here — vuls2 - // re-detects them from its own data. EUVD / MITRE contents can - // ride along on a detail but are never a detection basis + // admission gate minus the vuls2-migrated sources (NVD and + // Cisco), so NVD-only / Cisco-only detections disappear here — + // vuls2 re-detects them from its own data. EUVD / MITRE contents + // can ride along on a detail but are never a detection basis // (gocve neither matches nor admits on them, and // getMaxConfidence has no tier for them), so they do not keep // a detail alive. - if !detail.HasJvn() && !detail.HasCisco() && !detail.HasPaloalto() && !detail.HasFortinet() && !detail.HasVulncheck() { + if !detail.HasJvn() && !detail.HasPaloalto() && !detail.HasFortinet() && !detail.HasVulncheck() { continue } advisories := []models.DistroAdvisory{} - if detail.HasCisco() { - for _, cisco := range detail.Ciscos { - advisories = append(advisories, models.DistroAdvisory{ - AdvisoryID: cisco.AdvisoryID, - Severity: cisco.SIR, - Issued: cisco.FirstPublished, - Updated: cisco.LastUpdated, - Description: cisco.Summary, - }) - } - } - if detail.HasPaloalto() { for _, paloalto := range detail.Paloaltos { advisories = append(advisories, models.DistroAdvisory{ @@ -632,6 +618,7 @@ func detectCpeURIsCvesWithGoCVEDictionary(r *models.ScanResult, cpes []Cpe, cnf // detection. Deferring the strip keeps the earlier logic free of // per-source "had*" flags as more sources migrate to vuls2. detail.Nvds = nil + detail.Ciscos = nil maxConfidence := getMaxConfidence(detail) if val, ok := r.ScannedCves[detail.CveID]; ok { diff --git a/detector/vuls2/testdata/fixtures/enrich/cisco-json/data/2025/cisco-sa-n39k-isis-dos-JhJA8Rfx.json b/detector/vuls2/testdata/fixtures/enrich/cisco-json/data/2025/cisco-sa-n39k-isis-dos-JhJA8Rfx.json new file mode 100644 index 0000000000..a246033b1f --- /dev/null +++ b/detector/vuls2/testdata/fixtures/enrich/cisco-json/data/2025/cisco-sa-n39k-isis-dos-JhJA8Rfx.json @@ -0,0 +1,219 @@ +{ + "id": "cisco-sa-n39k-isis-dos-JhJA8Rfx", + "advisories": [ + { + "content": { + "id": "cisco-sa-n39k-isis-dos-JhJA8Rfx", + "title": "Cisco Nexus 3000 and 9000 Series Switches Intermediate System-to-Intermediate System Denial of Service Vulnerability", + "description": "\r\n

A vulnerability in the Intermediate System-to-Intermediate System (IS-IS) feature of Cisco NX-OS Software for Cisco Nexus 3000 Series Switches and Cisco Nexus 9000 Series Switches in standalone NX-OS mode could allow an unauthenticated, adjacent attacker to cause the IS-IS process to unexpectedly restart, which could cause an affected device to reload.

\r\n

This vulnerability is due to insufficient input validation when parsing an ingress IS-IS packet. An attacker could exploit this vulnerability by sending a crafted IS-IS packet to an affected device. A successful exploit could allow the attacker to cause the unexpected restart of the IS-IS process, which could cause the affected device to reload, resulting in a denial of service (DoS) condition.

\r\n

Note: The IS-IS protocol is a routing protocol. To exploit this vulnerability, an attacker must be Layer 2-adjacent to the affected device.

\r\n\r\n

Cisco has released software updates that address this vulnerability. There are no workarounds that address this vulnerability.

\r\n

This advisory is available at the following link:
https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx

\r\n\r\n

This advisory is part of the August 2025 Cisco FXOS and NX-OS Software Security Advisory Bundled Publication. For a complete list of the advisories and links to them, see Cisco Event Response: August 2025 Semiannual Cisco FXOS and NX-OS Software Security Advisory Bundled Publication.

\r\n", + "severity": [ + { + "type": "vendor", + "source": "cisco.com", + "vendor": "High" + } + ], + "cwe": [ + { + "source": "cisco.com", + "cwe": [ + "CWE-733" + ] + } + ], + "references": [ + { + "source": "cisco.com", + "url": "https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwn49153" + }, + { + "source": "cisco.com", + "url": "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx" + }, + { + "source": "cisco.com", + "url": "https://sec.cloudapps.cisco.com/security/center/contentjson/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx/csaf/cisco-sa-n39k-isis-dos-JhJA8Rfx.json" + }, + { + "source": "cisco.com", + "url": "https://sec.cloudapps.cisco.com/security/center/contentxml/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx/cvrf/cisco-sa-n39k-isis-dos-JhJA8Rfx_cvrf.xml" + } + ], + "published": "2025-08-27T16:00:00Z", + "modified": "2025-08-27T16:00:00Z" + }, + "segments": [ + { + "ecosystem": "cpe" + } + ] + } + ], + "vulnerabilities": [ + { + "content": { + "id": "CVE-2025-20241" + }, + "segments": [ + { + "ecosystem": "cpe" + } + ] + } + ], + "detections": [ + { + "ecosystem": "cpe", + "conditions": [ + { + "criteria": { + "operator": "OR", + "criterions": [ + { + "type": "cpe", + "cpe": { + "vulnerable": true, + "cpe": "cpe:2.3:o:cisco:nx-os:*:*:*:*:*:*:*:*", + "cpe_matches": [ + "cpe:2.3:o:cisco:nx-os:10.1\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.1\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.1\\(2t\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(1q\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(2a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(3t\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(3v\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(5\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(6\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(7\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.2\\(8\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(3o\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(3p\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(3q\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(3r\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(3w\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(3x\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(4a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(4g\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(4h\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(5\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(6\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(99w\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.3\\(99x\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.4\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.4\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.4\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.4\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.4\\(4g\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.5\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:10.5\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(10\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(10a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(11\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(11a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(11b\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(4a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(5\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(6\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(7\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(7a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(7b\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(8\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:6.0\\(2\\)a8\\(9\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)f3\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)f3\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)f3\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)f3\\(3a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)f3\\(3c\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)f3\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)f3\\(5\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(1t\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(5\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(6\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(6t\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(7\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(8\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(8a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(8b\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(8z\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i4\\(9\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i5\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i5\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i5\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i5\\(3a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i5\\(3b\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i6\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i6\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(10\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(3z\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(5\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(5a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(6\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(6z\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(7\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(8\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(9\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)i7\\(9w\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)ia7\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)ia7\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:7.0\\(3\\)im7\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.2\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.2\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.2\\(2t\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.2\\(2v\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.2\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.2\\(3y\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.2\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(10\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(11\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(12\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(13\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(14\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(1\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(1z\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(2\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(3\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(4\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(5\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(5w\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(6\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(7\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(7a\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(7k\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(8\\):*:*:*:*:*:*:*", + "cpe:2.3:o:cisco:nx-os:9.3\\(9\\):*:*:*:*:*:*:*" + ] + } + } + ] + } + } + ] + } + ], + "data_source": { + "id": "cisco-json", + "raws": [ + "vuls-data-raw-cisco-json/2025/cisco-sa-n39k-isis-dos-JhJA8Rfx.json" + ] + } +} \ No newline at end of file diff --git a/detector/vuls2/testdata/fixtures/enrich/cisco-json/datasource.json b/detector/vuls2/testdata/fixtures/enrich/cisco-json/datasource.json new file mode 100644 index 0000000000..8c46457bee --- /dev/null +++ b/detector/vuls2/testdata/fixtures/enrich/cisco-json/datasource.json @@ -0,0 +1,11 @@ +{ + "id": "cisco-json", + "name": "Cisco Security Advisory", + "raw": [ + { + "url": "ghcr.io/vulsio/vuls-data-db:vuls-data-raw-cisco-json", + "commit": "0000000000000000000000000000000000000000", + "date": "2025-01-01T00:00:00Z" + } + ] +} diff --git a/detector/vuls2/vendor.go b/detector/vuls2/vendor.go index aa86a5490c..d01f1682e6 100644 --- a/detector/vuls2/vendor.go +++ b/detector/vuls2/vendor.go @@ -803,8 +803,20 @@ type sourcePolicy struct { // policyFor returns the routing policy for a source. Sources not listed are // vulnerability-sourced with no presentation overrides (the existing default). -func policyFor(_ sourceTypes.SourceID) sourcePolicy { - return sourcePolicy{origin: originVulnerability} +func policyFor(sid sourceTypes.SourceID) sourcePolicy { + switch sid { + case sourceTypes.CiscoJSON: + return sourcePolicy{ + origin: originAdvisory, + refTag: "CISCO", + refHost: "cisco.com", + advisorySourceLink: func(id string) string { + return fmt.Sprintf("https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/%s", id) + }, + } + default: + return sourcePolicy{origin: originVulnerability} + } } // fromAdvContent projects an advisory's content into the neutral input. The @@ -1364,11 +1376,36 @@ func enrichAdvisories(vi *models.VulnInfo, advisories []dbTypes.VulnerabilityDat switch sourceID { case sourceTypes.ENISAKEV: vi.KEVs = append(vi.KEVs, enrichAdvisoryKEV(rootMap)...) + default: + if policyFor(sourceID).origin == originAdvisory { + enrichAdvisoryContent(vi, sourceID, rootMap) + } } } } } +// enrichAdvisoryContent backfills advisory-sourced CveContent (e.g. Cisco) for a +// CVE detected by another source. Like the detection path it builds content via +// the shared toCveContent, but passes nil provenance — enrich-built content +// carries no vuls2-sources. It is a no-op when the detection path already filled +// this content type (which carries provenance); a stale empty placeholder is +// dropped first so the backfill does not leave junk alongside the real content. +func enrichAdvisoryContent(vi *models.VulnInfo, sourceID sourceTypes.SourceID, rootMap map[dataTypes.RootID][]advisoryTypes.Advisory) { + cctype := toCveContentType(ecosystemTypes.EcosystemTypeCPE, sourceID) + if slices.ContainsFunc(vi.CveContents[cctype], func(c models.CveContent) bool { return !c.Empty() }) { + return + } + delete(vi.CveContents, cctype) + policy := policyFor(sourceID) + for _, advs := range rootMap { + for _, a := range advs { + vi.CveContents[cctype] = append(vi.CveContents[cctype], + toCveContent(fromAdvContent(cctype, vi.CveID, a.Content, policy), nil)) + } + } +} + // enrichMetasploit adds Metasploit module data to VulnInfo. func enrichMetasploit(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnerabilityTypes.Vulnerability) { if len(vi.Metasploits) > 0 { diff --git a/detector/vuls2/vuls2.go b/detector/vuls2/vuls2.go index e631466793..86a24bb7cb 100644 --- a/detector/vuls2/vuls2.go +++ b/detector/vuls2/vuls2.go @@ -1453,6 +1453,13 @@ func comparePack(a, b pack) (int, error) { // non-vuls2 input is an error). Only content-level fields are handled; // AffectedPackages / CpeURIs / WindowsKBFixedIns are aggregated separately // by postConvert after this merge. +// ccKey identifies a CveContent within a merge by its type and source link, so +// multiple same-type contents from distinct advisories are kept apart. +type ccKey struct { + ctype models.CveContentType + link string +} + func mergeVulnInfo(a, b models.VulnInfo) (models.VulnInfo, error) { if a.CveID != b.CveID { return models.VulnInfo{}, xerrors.Errorf("CVE IDs are different. a: %s, b: %s", a.CveID, b.CveID) @@ -1496,11 +1503,16 @@ func mergeVulnInfo(a, b models.VulnInfo) (models.VulnInfo, error) { } info.DistroAdvisories = slices.Collect(maps.Values(am)) - ccm := make(map[models.CveContentType]models.CveContent) + // Key by (type, source link), not type alone: a CVE can carry multiple + // CveContents of the same type from distinct advisories (e.g. several Cisco + // advisories), which must not collapse into one. For vulnerability-sourced + // types the source link is deterministic per CVE, so same-type contents still + // share a key and merge exactly as before. + ccm := make(map[ccKey]models.CveContent) for _, cciter := range []iter.Seq[[]models.CveContent]{maps.Values(a.CveContents), maps.Values(b.CveContents)} { for cc := range cciter { for _, c := range cc { - base, ok := ccm[c.Type] + base, ok := ccm[ccKey{c.Type, c.SourceLink}] if ok { var src1 []source if err := json.Unmarshal([]byte(base.Optional["vuls2-sources"]), &src1); err != nil { @@ -1611,13 +1623,13 @@ func mergeVulnInfo(a, b models.VulnInfo) (models.VulnInfo, error) { } else { base = c } - ccm[c.Type] = base + ccm[ccKey{c.Type, c.SourceLink}] = base } } } ccs := make(models.CveContents) - for cctype, cc := range ccm { - ccs[cctype] = []models.CveContent{cc} + for k, cc := range ccm { + ccs[k.ctype] = append(ccs[k.ctype], cc) } info.CveContents = ccs @@ -1779,6 +1791,7 @@ func enrich(sesh *session.Session, vim models.VulnInfos) error { }, DataSources: []sourceTypes.SourceID{ sourceTypes.CISAKEV, + sourceTypes.CiscoJSON, sourceTypes.ENISAKEV, sourceTypes.ExploitExploitDB, sourceTypes.ExploitGitHub, diff --git a/detector/vuls2/vuls2_test.go b/detector/vuls2/vuls2_test.go index 698591e23f..a83286faad 100644 --- a/detector/vuls2/vuls2_test.go +++ b/detector/vuls2/vuls2_test.go @@ -9148,6 +9148,153 @@ func Test_postConvert(t *testing.T) { }, }, }, + { + // Cisco's rich content lives in the advisory (the vulnerability is a + // thin CVE stub), so the detection path builds the cisco CveContent + // from the advisory and carries the vuls2-sources provenance — the + // stub never leaks into the emitted content. + name: "cpe cisco advisory-sourced content with provenance", + args: args{ + scanned: scanTypes.ScanResult{ + CPE: []string{ + "cpe:2.3:o:cisco:test_product:1.0:*:*:*:*:*:*:*", + }, + }, + fsToOriginalCPE: map[string][]string{ + "cpe:2.3:o:cisco:test_product:1.0:*:*:*:*:*:*:*": {"cpe:/o:cisco:test_product:1.0", "cpe:2.3:o:cisco:test_product:1.0:*:*:*:*:*:*:*"}, + }, + detected: detectTypes.DetectResult{ + Detected: []detectTypes.VulnerabilityData{ + { + ID: "cisco-sa-test", + Advisories: []dbTypes.VulnerabilityDataAdvisory{ + { + ID: "cisco-sa-test", + Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]advisoryTypes.Advisory{ + sourceTypes.CiscoJSON: { + dataTypes.RootID("cisco-sa-test"): []advisoryTypes.Advisory{ + { + Content: advisoryContentTypes.Content{ + ID: "cisco-sa-test", + Title: "Cisco Test Product Vulnerability", + Description: "A test Cisco vulnerability.", + Severity: []severityTypes.Severity{ + { + Type: severityTypes.SeverityTypeVendor, + Vendor: new("High"), + }, + }, + References: []referenceTypes.Reference{ + { + URL: "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-test", + }, + }, + Published: new(time.Date(2025, 8, 27, 16, 0, 0, 0, time.UTC)), + Modified: new(time.Date(2025, 8, 27, 16, 0, 0, 0, time.UTC)), + }, + Segments: []segmentTypes.Segment{ + { + Ecosystem: ecosystemTypes.EcosystemTypeCPE, + }, + }, + }, + }, + }, + }, + }, + }, + Vulnerabilities: []dbTypes.VulnerabilityDataVulnerability{ + { + ID: "CVE-2099-0001", + Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]vulnerabilityTypes.Vulnerability{ + sourceTypes.CiscoJSON: { + dataTypes.RootID("cisco-sa-test"): []vulnerabilityTypes.Vulnerability{ + { + Content: vulnerabilityContentTypes.Content{ + ID: "CVE-2099-0001", + }, + Segments: []segmentTypes.Segment{ + { + Ecosystem: ecosystemTypes.EcosystemTypeCPE, + }, + }, + }, + }, + }, + }, + }, + }, + Detections: []detectTypes.VulnerabilityDataDetection{ + { + Ecosystem: ecosystemTypes.EcosystemTypeCPE, + Contents: map[sourceTypes.SourceID][]conditionTypes.FilteredCondition{ + sourceTypes.CiscoJSON: { + { + Criteria: criteriaTypes.FilteredCriteria{ + Operator: criteriaTypes.CriteriaOperatorTypeOR, + Criterions: []criterionTypes.FilteredCriterion{ + { + Criterion: criterionTypes.Criterion{ + Type: criterionTypes.CriterionTypeCPE, + CPE: new(ccTypes.Criterion{ + Vulnerable: true, + FixStatus: new(vcFixStatusTypes.FixStatus{ + Class: vcFixStatusTypes.ClassUnknown, + }), + CPE: ccTypes.CPE("cpe:2.3:o:cisco:test_product:*:*:*:*:*:*:*:*"), + }), + }, + Accepts: criterionTypes.AcceptQueries{ + CPE: criterionTypes.CPEAccepts{Exact: []int{0}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: models.VulnInfos{ + "CVE-2099-0001": { + CveID: "CVE-2099-0001", + Confidences: models.Confidences{models.CiscoExactVersionMatch}, + CpeURIs: []string{"cpe:/o:cisco:test_product:1.0", "cpe:2.3:o:cisco:test_product:1.0:*:*:*:*:*:*:*"}, + DistroAdvisories: models.DistroAdvisories{ + { + AdvisoryID: "cisco-sa-test", + Severity: "High", + Issued: time.Date(2025, 8, 27, 16, 0, 0, 0, time.UTC), + Updated: time.Date(2025, 8, 27, 16, 0, 0, 0, time.UTC), + Description: "A test Cisco vulnerability.", + }, + }, + CveContents: models.CveContents{ + models.Cisco: []models.CveContent{ + { + Type: models.Cisco, + CveID: "CVE-2099-0001", + Title: "Cisco Test Product Vulnerability", + Summary: "A test Cisco vulnerability.", + SourceLink: "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-test", + References: models.References{ + {Link: "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-test", Source: "CISCO"}, + }, + Published: time.Date(2025, 8, 27, 16, 0, 0, 0, time.UTC), + LastModified: time.Date(2025, 8, 27, 16, 0, 0, 0, time.UTC), + Optional: map[string]string{ + "vuls2-sources": "[{\"root_id\":\"cisco-sa-test\",\"source_id\":\"cisco-json\",\"segment\":{\"ecosystem\":\"cpe\"}}]", + }, + }, + }, + }, + }, + }, + }, { // A criterion accepted the query only at version-unconfirmed // quality (the upstream matcher could not confirm the scanned @@ -11161,6 +11308,45 @@ func Test_enrich(t *testing.T) { }, }, }, + { + // Cisco content lives in the advisory (not the vulnerability), so + // enrichCisco reads advisory roots and builds the cisco CveContent; + // Cisco carries no CVSS (only a vendor SIR -> DistroAdvisory severity). + // Fixture is a real vuls-data-update PR #844 Cisco golden. + name: "enrich with cisco (advisory-sourced content, no CVSS)", + args: args{ + vim: models.VulnInfos{ + "CVE-2025-20241": models.VulnInfo{ + CveID: "CVE-2025-20241", + }, + }, + }, + want: models.VulnInfos{ + "CVE-2025-20241": models.VulnInfo{ + CveID: "CVE-2025-20241", + CveContents: models.CveContents{ + models.Cisco: []models.CveContent{ + { + Type: models.Cisco, + CveID: "CVE-2025-20241", + Title: "Cisco Nexus 3000 and 9000 Series Switches Intermediate System-to-Intermediate System Denial of Service Vulnerability", + Summary: "\r\n

A vulnerability in the Intermediate System-to-Intermediate System (IS-IS) feature of Cisco NX-OS Software for Cisco Nexus 3000 Series Switches and Cisco Nexus 9000 Series Switches in standalone NX-OS mode could allow an unauthenticated, adjacent attacker to cause the IS-IS process to unexpectedly restart, which could cause an affected device to reload.

\r\n

This vulnerability is due to insufficient input validation when parsing an ingress IS-IS packet. An attacker could exploit this vulnerability by sending a crafted IS-IS packet to an affected device. A successful exploit could allow the attacker to cause the unexpected restart of the IS-IS process, which could cause the affected device to reload, resulting in a denial of service (DoS) condition.

\r\n

Note: The IS-IS protocol is a routing protocol. To exploit this vulnerability, an attacker must be Layer 2-adjacent to the affected device.

\r\n\r\n

Cisco has released software updates that address this vulnerability. There are no workarounds that address this vulnerability.

\r\n

This advisory is available at the following link:
https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx

\r\n\r\n

This advisory is part of the August 2025 Cisco FXOS and NX-OS Software Security Advisory Bundled Publication. For a complete list of the advisories and links to them, see Cisco Event Response: August 2025 Semiannual Cisco FXOS and NX-OS Software Security Advisory Bundled Publication.

\r\n", + SourceLink: "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx", + References: models.References{ + {Link: "https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwn49153", Source: "CISCO"}, + {Link: "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx", Source: "CISCO"}, + {Link: "https://sec.cloudapps.cisco.com/security/center/contentjson/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx/csaf/cisco-sa-n39k-isis-dos-JhJA8Rfx.json", Source: "CISCO"}, + {Link: "https://sec.cloudapps.cisco.com/security/center/contentxml/CiscoSecurityAdvisory/cisco-sa-n39k-isis-dos-JhJA8Rfx/cvrf/cisco-sa-n39k-isis-dos-JhJA8Rfx_cvrf.xml", Source: "CISCO"}, + }, + CweIDs: []string{"CWE-733"}, + Published: time.Date(2025, time.August, 27, 16, 0, 0, 0, time.UTC), + LastModified: time.Date(2025, time.August, 27, 16, 0, 0, 0, time.UTC), + }, + }, + }, + }, + }, + }, } c := session.Config{Type: "boltdb", Path: filepath.Join(t.TempDir(), "enrich-test.db")}