diff --git a/detector/detector.go b/detector/detector.go index 7369af94b3..81f00cca6e 100644 --- a/detector/detector.go +++ b/detector/detector.go @@ -397,8 +397,10 @@ func DetectWordPressCves(r *models.ScanResult, wpCnf config.WpScanConf) error { return nil } -// FillCvesWithGoCVEDictionary fills CVE detail with VulnCheck, JVN, Fortinet, Paloalto, Cisco -// (NVD CveContent/exploits/mitigations, EUVD, and MITRE are filled by the vuls2 enrich path instead; JP-CERT alerts still come from here) +// FillCvesWithGoCVEDictionary fills CVE detail with VulnCheck, JVN, Fortinet, Paloalto +// (NVD CveContent, EUVD, and MITRE are filled by the vuls2 enrich path instead, as are the US-CERT +// alerts; Cisco is detected by vuls2, which emits its DistroAdvisory and a sparse CveContent; only the +// JP-CERT alerts, derived from JVN, still come from here) func FillCvesWithGoCVEDictionary(r *models.ScanResult, cnf config.GoCveDictConf, logOpts logging.LogOpts) (err error) { cveIDs := make([]string, 0, len(r.ScannedCves)) for _, v := range r.ScannedCves { @@ -425,7 +427,6 @@ func FillCvesWithGoCVEDictionary(r *models.ScanResult, cnf config.GoCveDictConf, jvns := models.ConvertJvnToModel(d.CveID, d.Jvns) fortinets := models.ConvertFortinetToModel(d.CveID, d.Fortinets) paloaltos := models.ConvertPaloaltoToModel(d.CveID, d.Paloaltos) - ciscos := models.ConvertCiscoToModel(d.CveID, d.Ciscos) alerts := fillCertAlerts(&d) for cveID, vinfo := range r.ScannedCves { @@ -441,7 +442,7 @@ func FillCvesWithGoCVEDictionary(r *models.ScanResult, cnf config.GoCveDictConf, for _, con := range vulnchecks { vinfo.CveContents[con.Type] = append(vinfo.CveContents[con.Type], con) } - for _, cons := range [][]models.CveContent{jvns, fortinets, paloaltos, ciscos} { + for _, cons := range [][]models.CveContent{jvns, fortinets, paloaltos} { for _, con := range cons { if !con.Empty() { if !slices.ContainsFunc(vinfo.CveContents[con.Type], func(e models.CveContent) bool { @@ -539,30 +540,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 + // 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{ @@ -619,6 +608,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/vendor.go b/detector/vuls2/vendor.go index 39c2759631..e2fc1c4455 100644 --- a/detector/vuls2/vendor.go +++ b/detector/vuls2/vendor.go @@ -632,8 +632,12 @@ func cveContentOptional(e ecosystemTypes.Ecosystem, v vulnerabilityTypes.Vulnera return m } -func cveContentSourceLink(ccType models.CveContentType, v vulnerabilityTypes.Vulnerability) string { +func cveContentSourceLink(ccType models.CveContentType, v vulnerabilityTypes.Vulnerability, rootID dataTypes.RootID) string { switch ccType { + case models.Cisco: + // Cisco content lives in the advisory, whose ID is the root ID, so the + // per-CVE source link points at that advisory page. + return fmt.Sprintf("https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/%s", rootID) case models.RedHat, models.RedHatAPI: return fmt.Sprintf("https://access.redhat.com/security/cve/%s", v.Content.ID) case models.Oracle: @@ -1279,7 +1283,7 @@ func enrichRedHatCVE(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnera if _, ok := vi.CveContents[models.RedHatAPI]; ok { return } - for _, vulns := range rootMap { + for rootID, vulns := range rootMap { for _, v := range vulns { cvss2, cvss3, cvss40 := enrichCvss(v.Content.Severity) @@ -1288,7 +1292,7 @@ func enrichRedHatCVE(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnera rs = append(rs, toReference(r.URL)) } - sourceLink := cveContentSourceLink(models.RedHatAPI, v) + sourceLink := cveContentSourceLink(models.RedHatAPI, v, rootID) for _, m := range v.Content.Mitigations { if m.Description == "" { continue @@ -1349,7 +1353,7 @@ func enrichRedHatCVE(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnera // populate AlertDict. func enrichNVD(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnerabilityTypes.Vulnerability) { _, hasContent := vi.CveContents[models.Nvd] - for _, vulns := range rootMap { + for rootID, vulns := range rootMap { for _, v := range vulns { // US-CERT alerts: an NVD reference whose URL contains "us-cert" // (mirrors go-cve-dictionary's NvdCert derivation). Done regardless @@ -1414,7 +1418,7 @@ func enrichNVD(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnerability Cvss40Score: cvss40.Score, Cvss40Vector: cvss40.Vector, Cvss40Severity: cvss40.Severity, - SourceLink: cveContentSourceLink(models.Nvd, v), + SourceLink: cveContentSourceLink(models.Nvd, v, rootID), References: rs, CweIDs: func() []string { var cs []string //nolint:prealloc @@ -1458,7 +1462,7 @@ func enrichMitreCVE(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnerab ssvc *models.SSVC } - for _, vulns := range rootMap { + for rootID, vulns := range rootMap { for _, v := range vulns { // Group every per-source field by its CNA/ADP source in a single pass. bySource := map[string]*mitreBySource{} @@ -1492,7 +1496,7 @@ func enrichMitreCVE(vi *models.VulnInfo, rootMap map[dataTypes.RootID][]vulnerab // ("CNA"/"ADP"), set by the extractor from structural position. containerTypes := mitreContainerTypes(v.Content.Optional) - sourceLink := cveContentSourceLink(models.Mitre, v) + sourceLink := cveContentSourceLink(models.Mitre, v, rootID) published := func() time.Time { if v.Content.Published != nil { return *v.Content.Published diff --git a/detector/vuls2/vuls2.go b/detector/vuls2/vuls2.go index 37297df6f1..a4e21ce19f 100644 --- a/detector/vuls2/vuls2.go +++ b/detector/vuls2/vuls2.go @@ -1352,7 +1352,7 @@ func walkVulnerabilityDatas(m map[source]sourceData, vds []detectTypes.Vulnerabi Cvss40Score: cvss40.Score, Cvss40Vector: cvss40.Vector, Cvss40Severity: cvss40.Severity, - SourceLink: cveContentSourceLink(cctype, v), + SourceLink: cveContentSourceLink(cctype, v, src.RootID), References: rs, CweIDs: func() []string { var cs []string diff --git a/detector/vuls2/vuls2_test.go b/detector/vuls2/vuls2_test.go index df7ff3eda3..b3b7bf79bc 100644 --- a/detector/vuls2/vuls2_test.go +++ b/detector/vuls2/vuls2_test.go @@ -14,6 +14,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" conditionTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition" 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" @@ -9148,6 +9149,171 @@ func Test_postConvert(t *testing.T) { }, }, }, + { + // Cisco is advisory-shaped (content in advisories[], M:N with CVEs, + // the vulnerability entry a bare CVE-ID stub), so the detection path + // emits a DistroAdvisory plus a sparse per-CVE CveContent whose source + // link points at the advisory (no CVSS/title/summary in the stub). + name: "cpe cisco detection emits DistroAdvisory and CveContent (source link to advisory)", + args: args{ + scanned: scanTypes.ScanResult{ + CPE: []string{ + "cpe:2.3:a:cisco:firepower_threat_defense:7.4.0.0:*:*:*:*:*:*:*", + }, + }, + fsToOriginalCPE: map[string][]string{ + "cpe:2.3:a:cisco:firepower_threat_defense:7.4.0.0:*:*:*:*:*:*:*": {"cpe:/a:cisco:firepower_threat_defense:7.4.0.0", "cpe:2.3:a:cisco:firepower_threat_defense:7.4.0.0:*:*:*:*:*:*:*"}, + }, + detected: detectTypes.DetectResult{ + Detected: []detectTypes.VulnerabilityData{ + { + ID: "cisco-sa-3100_4200_tlsdos-2yNSCd54", + Advisories: []dbTypes.VulnerabilityDataAdvisory{ + { + ID: "cisco-sa-3100_4200_tlsdos-2yNSCd54", + Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]advisoryTypes.Advisory{ + sourceTypes.CiscoJSON: { + dataTypes.RootID("cisco-sa-3100_4200_tlsdos-2yNSCd54"): []advisoryTypes.Advisory{ + { + Content: advisoryContentTypes.Content{ + ID: "cisco-sa-3100_4200_tlsdos-2yNSCd54", + Title: "Cisco Secure Firewall Adaptive Security Appliance and Secure Firewall Threat Defense Software for Firepower 3100 and 4200 Series TLS 1.3 Cipher Denial of Service Vulnerability", + Description: "A vulnerability in the TLS 1.3 implementation for a specific cipher for Cisco Secure Firewall ASA and FTD Software for Firepower 3100 and 4200 Series devices could allow an authenticated, remote attacker to cause a denial of service (DoS) condition.", + Severity: []severityTypes.Severity{ + { + Type: severityTypes.SeverityTypeVendor, + Source: "cisco.com", + Vendor: new("High"), + }, + }, + CWE: []cweTypes.CWE{ + { + Source: "cisco.com", + CWE: []string{"CWE-404"}, + }, + }, + References: []referenceTypes.Reference{ + { + Source: "cisco.com", + URL: "https://bst.cloudapps.cisco.com/bugsearch/bug/CSCwm91176", + }, + { + Source: "cisco.com", + URL: "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-3100_4200_tlsdos-2yNSCd54", + }, + }, + Published: new(time.Date(2025, 8, 14, 16, 0, 0, 0, time.UTC)), + Modified: new(time.Date(2025, 9, 3, 13, 37, 50, 0, time.UTC)), + }, + Segments: []segmentTypes.Segment{ + { + Ecosystem: ecosystemTypes.EcosystemTypeCPE, + }, + }, + }, + }, + }, + }, + }, + }, + Vulnerabilities: []dbTypes.VulnerabilityDataVulnerability{ + { + ID: "CVE-2025-20127", + Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]vulnerabilityTypes.Vulnerability{ + sourceTypes.CiscoJSON: { + dataTypes.RootID("cisco-sa-3100_4200_tlsdos-2yNSCd54"): []vulnerabilityTypes.Vulnerability{ + { + Content: vulnerabilityContentTypes.Content{ + ID: "CVE-2025-20127", + }, + 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:a:cisco:firepower_threat_defense:*:*:*:*:*:*:*:*"), + }), + }, + Accepts: criterionTypes.AcceptQueries{ + CPE: criterionTypes.CPEAccepts{Exact: []int{0}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: models.VulnInfos{ + "CVE-2025-20127": { + CveID: "CVE-2025-20127", + Confidences: models.Confidences{models.CiscoExactVersionMatch}, + CpeURIs: []string{"cpe:/a:cisco:firepower_threat_defense:7.4.0.0", "cpe:2.3:a:cisco:firepower_threat_defense:7.4.0.0:*:*:*:*:*:*:*"}, + DistroAdvisories: models.DistroAdvisories{ + { + AdvisoryID: "cisco-sa-3100_4200_tlsdos-2yNSCd54", + Severity: "High", + Issued: time.Date(2025, 8, 14, 16, 0, 0, 0, time.UTC), + Updated: time.Date(2025, 9, 3, 13, 37, 50, 0, time.UTC), + Description: "A vulnerability in the TLS 1.3 implementation for a specific cipher for Cisco Secure Firewall ASA and FTD Software for Firepower 3100 and 4200 Series devices could allow an authenticated, remote attacker to cause a denial of service (DoS) condition.", + }, + }, + // Cisco is advisory-shaped: the vulnerability stub carries + // only the CVE-ID, so the CveContent is sparse (no CVSS, + // title, or summary) and its source link points at the + // advisory (the root ID). + CveContents: models.CveContents{ + models.Cisco: []models.CveContent{ + { + Type: models.Cisco, + CveID: "CVE-2025-20127", + SourceLink: "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-3100_4200_tlsdos-2yNSCd54", + References: models.References{ + { + Link: "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-3100_4200_tlsdos-2yNSCd54", + Source: "CISCO", + RefID: "cisco-sa-3100_4200_tlsdos-2yNSCd54", + }, + }, + Published: time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC), + LastModified: time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC), + Optional: map[string]string{ + "vuls2-sources": "[{\"root_id\":\"cisco-sa-3100_4200_tlsdos-2yNSCd54\",\"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 diff --git a/tui/tui.go b/tui/tui.go index 62bfe491d2..854fdf7d3b 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -3,6 +3,7 @@ package tui import ( "bytes" "cmp" + "errors" "fmt" "os" "slices" @@ -56,7 +57,10 @@ func RunTui(results models.ScanResults) subcommands.ExitStatus { g.SelFgColor = gocui.ColorBlack g.Cursor = true - if err := g.MainLoop(); err != nil { + // gocui's MainLoop returns ErrQuit on a normal quit and never returns nil, so + // treat ErrQuit as a clean exit (the deferred g.Close runs) and exit only on a + // real error. + if err := g.MainLoop(); !errors.Is(err, gocui.ErrQuit) { g.Close() logging.Log.Errorf("%+v", err) os.Exit(1)