diff --git a/cmd/clair/main.go b/cmd/clair/main.go index e28a30d5..62f5d00c 100644 --- a/cmd/clair/main.go +++ b/cmd/clair/main.go @@ -58,6 +58,7 @@ import ( _ "github.com/coreos/clair/ext/vulnsrc/debian" _ "github.com/coreos/clair/ext/vulnsrc/oracle" _ "github.com/coreos/clair/ext/vulnsrc/rhel" + _ "github.com/coreos/clair/ext/vulnsrc/suse" _ "github.com/coreos/clair/ext/vulnsrc/ubuntu" ) diff --git a/ext/featurens/osrelease/osrelease.go b/ext/featurens/osrelease/osrelease.go index 8e736743..e52ff7b2 100644 --- a/ext/featurens/osrelease/osrelease.go +++ b/ext/featurens/osrelease/osrelease.go @@ -84,7 +84,7 @@ func (d detector) Detect(files tarutil.FilesMap) (*database.Namespace, error) { switch OS { case "debian", "ubuntu": versionFormat = dpkg.ParserName - case "centos", "rhel", "fedora", "amzn", "ol", "oracle": + case "centos", "rhel", "fedora", "amzn", "ol", "oracle", "opensuse", "sles": versionFormat = rpm.ParserName default: return nil, nil diff --git a/ext/vulnsrc/suse/suse.go b/ext/vulnsrc/suse/suse.go new file mode 100644 index 00000000..815668ea --- /dev/null +++ b/ext/vulnsrc/suse/suse.go @@ -0,0 +1,478 @@ +// Copyright 2017 clair authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package suse implements a vulnerability source updater using the +// SUSE Linux and openSUSE OVAL Database. +package suse + +import ( + "bufio" + "encoding/xml" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/coreos/clair/database" + "github.com/coreos/clair/ext/versionfmt" + "github.com/coreos/clair/ext/versionfmt/rpm" + "github.com/coreos/clair/ext/vulnsrc" + "github.com/coreos/clair/pkg/commonerr" +) + +const ( + ovalURI = "http://ftp.suse.com/pub/projects/security/oval/" + + // "Thu, 30 Nov 2017 03:07:57 GMT + timeFormatLastModified = "Mon, 2 Jan 2006 15:04:05 MST" + + // timestamp format 2017-10-23T04:07:14 + timeFormatOVAL = "2006-1-2T15:04:05" +) + +var ( + ignoredCriterions []string + suseOpenSUSEInstalledCommentRegexp = regexp.MustCompile(`(SUSE Linux Enterprise |openSUSE ).*is installed`) + suseInstalledCommentRegexp = regexp.MustCompile(`SUSE Linux Enterprise[A-Za-z\s]*? (\d+)[\w\s]*?(SP(\d+))? is installed`) +) + +type oval struct { + Timestamp string `xml:"generator>timestamp"` + Definitions []definition `xml:"definitions>definition"` +} + +type definition struct { + Title string `xml:"metadata>title"` + Description string `xml:"metadata>description"` + References []reference `xml:"metadata>reference"` + Criteria criteria `xml:"criteria"` +} + +type reference struct { + Source string `xml:"source,attr"` + URI string `xml:"ref_url,attr"` +} + +type criteria struct { + Operator string `xml:"operator,attr"` + Criterias []*criteria `xml:"criteria"` + Criterions []criterion `xml:"criterion"` +} + +type criterion struct { + Comment string `xml:"comment,attr"` +} + +type flavor int + +const ( + SUSE flavor = iota + OpenSUSE +) + +type updater struct { + Name string + NamespaceName string + FilePrefix string + UpdaterFlag string + FileRegexp *regexp.Regexp +} + +func newUpdater(f flavor) updater { + var up updater + + switch f { + case SUSE: + up.Name = "SUSE Linux" + up.NamespaceName = "sles" + up.FilePrefix = "suse.linux.enterprise." + up.UpdaterFlag = "SUSEUpdater" + up.FileRegexp = regexp.MustCompile(`suse.linux.enterprise.(\d+).xml`) + case OpenSUSE: + up.Name = "openSUSE" + up.NamespaceName = "opensuse" + up.FilePrefix = "opensuse.leap." + up.UpdaterFlag = "openSUSEUpdater" + up.FileRegexp = regexp.MustCompile(`opensuse.leap.(\d+\.*\d*).xml`) + default: + panic("tried to create an updater for an unrecognized flavor of openSUSE/SUSE") + } + + return up +} + +func init() { + suseUpdater := newUpdater(SUSE) + openSUSEUpdater := newUpdater(OpenSUSE) + vulnsrc.RegisterUpdater("suse", &suseUpdater) + vulnsrc.RegisterUpdater("opensuse", &openSUSEUpdater) +} + +func (u *updater) Update(datastore database.Datastore) (resp vulnsrc.UpdateResponse, err error) { + log.WithField("package", u.Name).Info("Start fetching vulnerabilities") + + tx, err := datastore.Begin() + if err != nil { + return resp, err + } + defer tx.Rollback() + + // openSUSE and SUSE have one single xml file for all the products, there are no incremental + // xml files. We store into the database the value of the generation timestamp + // of the latest file we parsed. + flagValue, ok, err := tx.FindKeyValue(u.UpdaterFlag) + if err != nil { + return resp, err + } + log.WithField("flagvalue", flagValue).Debug("Generation timestamp of latest parsed file") + + if !ok { + flagValue = "0" + } + + // this contains the modification time of the most recent + // file expressed as unix time (int64) + latestOval, err := strconv.ParseInt(flagValue, 10, 64) + if err != nil { + // something went wrong, force parsing of all files + latestOval = 0 + } + + // Fetch the update list. + r, err := http.Get(ovalURI) + if err != nil { + err = fmt.Errorf("Cannot download SUSE update list: %v", err) + return resp, err + } + defer r.Body.Close() + + var ovalFiles []string + var generationTimes []int64 + + scanner := bufio.NewScanner(r.Body) + for scanner.Scan() { + line := scanner.Text() + r := u.FileRegexp.FindStringSubmatch(line) + if len(r) != 2 { + continue + } + + ovalFile := ovalURI + u.FilePrefix + r[1] + ".xml" + log.WithFields(log.Fields{ + "ovalFile": ovalFile, + "updater": u.Name, + }).Debug("file to check") + + // Do not fetch the entire file to get the value of the + // creation time. Rely on the "latest modified time" + // value of the file hosted on the remote server. + timestamp, err := getLatestModifiedTime(ovalFile) + if err != nil { + log.WithError(err).WithField("ovalFile", ovalFile).Warning("Ignoring OVAL file") + } + + if timestamp > latestOval { + ovalFiles = append(ovalFiles, ovalFile) + } + } + + for _, oval := range ovalFiles { + // Download the oval XML file. + r, err := http.Get(oval) + if err != nil { + log.WithError(err).Error("could not download", u.Name, "update list") + return resp, commonerr.ErrCouldNotDownload + } + defer r.Body.Close() + + match := u.FileRegexp.FindStringSubmatch(oval) + if len(match) != 2 { + log.Error("Skipping ", oval, "because it's not possible to extract osVersion") + continue + } + osVersion := match[1] + + // Parse the XML. + vs, generationTime, err := parseOval(r.Body, u.NamespaceName, osVersion) + if err != nil { + return resp, err + } + generationTimes = append(generationTimes, generationTime) + + // Collect vulnerabilities. + resp.Vulnerabilities = append(resp.Vulnerabilities, vs...) + } + + // Set the flag if we found anything. + if len(generationTimes) > 0 { + resp.FlagName = u.UpdaterFlag + resp.FlagValue = strconv.FormatInt(latest(generationTimes), 10) + } else { + log.WithField("package", u.Name).Debug("no update") + } + + return resp, nil +} + +// Get the latest modification time of a remote file +// expressed as unix time +func getLatestModifiedTime(url string) (int64, error) { + resp, err := http.Head(url) + if err != nil { + return 0, err + } + + last_modified := resp.Header.Get("Last-Modified") + if len(last_modified) == 0 { + return 0, fmt.Errorf("last modified header missing") + } + + timestamp, err := time.Parse(timeFormatLastModified, last_modified) + if err != nil { + return 0, err + } + + return timestamp.Unix(), nil +} + +func latest(values []int64) (ret int64) { + for _, element := range values { + if element > ret { + ret = element + } + } + return +} + +func (u *updater) Clean() {} + +func parseOval(ovalReader io.Reader, osFlavor, osVersion string) (vulnerabilities []database.VulnerabilityWithAffected, generationTime int64, err error) { + // Decode the XML. + var ov oval + err = xml.NewDecoder(ovalReader).Decode(&ov) + if err != nil { + log.WithError(err).Error("could not decode XML") + err = commonerr.ErrCouldNotParse + return + } + + timestamp, err := time.Parse(timeFormatOVAL, ov.Timestamp) + if err != nil { + return + } + generationTime = timestamp.Unix() + + // Iterate over the definitions and collect any vulnerabilities that affect + // at least one package. + for _, definition := range ov.Definitions { + pkgs := toFeatureVersions(definition.Criteria, osFlavor, osVersion) + if len(pkgs) > 0 { + vulnerability := database.VulnerabilityWithAffected{ + Vulnerability: database.Vulnerability{ + Name: name(definition), + Link: link(definition), + Severity: severity(definition), + Description: description(definition), + }, + } + vulnerability.Affected = append(vulnerability.Affected, pkgs...) + vulnerabilities = append(vulnerabilities, vulnerability) + } + } + + return +} + +func getCriterions(node criteria) [][]criterion { + // Filter useless criterions. + var criterions []criterion + for _, c := range node.Criterions { + ignored := false + + for _, ignoredItem := range ignoredCriterions { + if strings.Contains(c.Comment, ignoredItem) { + ignored = true + break + } + } + + if !ignored { + criterions = append(criterions, c) + } + } + + if node.Operator == "AND" { + return [][]criterion{criterions} + } else if node.Operator == "OR" { + var possibilities [][]criterion + for _, c := range criterions { + possibilities = append(possibilities, []criterion{c}) + } + return possibilities + } + + return [][]criterion{} +} + +func getPossibilities(node criteria) [][]criterion { + if len(node.Criterias) == 0 { + return getCriterions(node) + } + + var possibilitiesToCompose [][][]criterion + for _, criteria := range node.Criterias { + possibilitiesToCompose = append(possibilitiesToCompose, getPossibilities(*criteria)) + } + if len(node.Criterions) > 0 { + possibilitiesToCompose = append(possibilitiesToCompose, getCriterions(node)) + } + + var possibilities [][]criterion + if node.Operator == "AND" { + for _, possibility := range possibilitiesToCompose[0] { + possibilities = append(possibilities, possibility) + } + + for _, possibilityGroup := range possibilitiesToCompose[1:] { + var newPossibilities [][]criterion + + for _, possibility := range possibilities { + for _, possibilityInGroup := range possibilityGroup { + var p []criterion + p = append(p, possibility...) + p = append(p, possibilityInGroup...) + newPossibilities = append(newPossibilities, p) + } + } + + possibilities = newPossibilities + } + } else if node.Operator == "OR" { + for _, possibilityGroup := range possibilitiesToCompose { + for _, possibility := range possibilityGroup { + possibilities = append(possibilities, possibility) + } + } + } + + return possibilities +} + +func toFeatureVersions(criteria criteria, osFlavor, osVersion string) []database.AffectedFeature { + // There are duplicates in SUSE .xml files. + // This map is for deduplication. + featureVersionParameters := make(map[string]database.AffectedFeature) + + possibilities := getPossibilities(criteria) + for _, criterions := range possibilities { + var featureVersion database.AffectedFeature + + // Attempt to parse package data from trees of criterions. + for _, c := range criterions { + if match := suseInstalledCommentRegexp.FindStringSubmatch(c.Comment); match != nil { + if len(match) == 4 { + osVersion = match[1] + if match[3] != "" { + osVersion = fmt.Sprintf("%s.%s", osVersion, match[3]) + } + } else { + log.WithField("comment", c.Comment).Warning("could not extract sles name and version from comment") + } + } + + if suseOpenSUSEInstalledCommentRegexp.FindStringSubmatch(c.Comment) == nil && strings.HasSuffix(c.Comment, " is installed") { + name, version, err := splitPackageNameAndVersion(c.Comment[:len(c.Comment)-len(" is installed")]) + if err != nil { + log.WithError(err).WithField("comment", c.Comment).Warning("Could not extract package name and version from comment") + } else { + featureVersion.FeatureName = name + version := version + err := versionfmt.Valid(rpm.ParserName, version) + if err != nil { + log.WithError(err).WithField("version", version).Warning("could not parse package version. skipping") + } else { + featureVersion.AffectedVersion = version + if version != versionfmt.MaxVersion { + featureVersion.FixedInVersion = version + } + } + } + } + } + + featureVersion.Namespace.Name = fmt.Sprintf("%s:%s", osFlavor, osVersion) + featureVersion.Namespace.VersionFormat = rpm.ParserName + + if featureVersion.Namespace.Name != "" && featureVersion.FeatureName != "" && featureVersion.AffectedVersion != "" && featureVersion.FixedInVersion != "" { + featureVersionParameters[featureVersion.Namespace.Name+":"+featureVersion.FeatureName] = featureVersion + } else { + log.WithField("criterions", fmt.Sprintf("%v", criterions)).Warning("could not determine a valid package from criterions") + } + } + + // Convert the map to slice. + var featureVersionParametersArray []database.AffectedFeature + for _, fv := range featureVersionParameters { + featureVersionParametersArray = append(featureVersionParametersArray, fv) + } + + return featureVersionParametersArray +} + +func description(def definition) (desc string) { + // It is much more faster to proceed like this than using a Replacer. + desc = strings.Replace(def.Description, "\n\n\n", " ", -1) + desc = strings.Replace(desc, "\n\n", " ", -1) + desc = strings.Replace(desc, "\n", " ", -1) + return +} + +func name(def definition) string { + return strings.TrimSpace(def.Title) +} + +func link(def definition) (link string) { + for _, reference := range def.References { + if reference.Source == "CVE" { + link = reference.URI + break + } + } + + return +} + +func severity(def definition) (severity database.Severity) { + //TODO: handle that once openSUSE/SLE OVAL files have severity info + return database.UnknownSeverity +} + +func splitPackageNameAndVersion(fullname string) (name, version string, err error) { + re := regexp.MustCompile(`-\d+\.`) + + matches := re.FindStringSubmatchIndex(fullname) + if matches == nil { + err = fmt.Errorf("Cannot extract package name and version from %s", fullname) + } else { + name = fullname[:matches[0]] + version = fullname[matches[0]+1:] + } + + return +} diff --git a/ext/vulnsrc/suse/suse_test.go b/ext/vulnsrc/suse/suse_test.go new file mode 100644 index 00000000..563a18cd --- /dev/null +++ b/ext/vulnsrc/suse/suse_test.go @@ -0,0 +1,154 @@ +// Copyright 2017 clair authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package suse + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/coreos/clair/database" + "github.com/coreos/clair/ext/versionfmt/rpm" + "github.com/stretchr/testify/assert" +) + +func TestOpenSUSEParser(t *testing.T) { + _, filename, _, _ := runtime.Caller(0) + path := filepath.Join(filepath.Dir(filename)) + + // Test parsing testdata/fetcher_opensuse_test.1.xml + testFile, _ := os.Open(path + "/testdata/fetcher_opensuse_test.1.xml") + defer testFile.Close() + + u := newUpdater(OpenSUSE) + osVersion := "42.3" + + vulnerabilities, generationTime, err := parseOval(testFile, u.NamespaceName, osVersion) + assert.Nil(t, err) + assert.Equal(t, int64(1467000286), generationTime) + + if assert.Nil(t, err) && assert.Len(t, vulnerabilities, 1) { + assert.Equal(t, "CVE-2012-2150", vulnerabilities[0].Name) + assert.Equal(t, "http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2012-2150", vulnerabilities[0].Link) + assert.Equal(t, `xfs_metadump in xfsprogs before 3.2.4 does not properly obfuscate file data, which allows remote attackers to obtain sensitive information by reading a generated image.`, vulnerabilities[0].Description) + + expectedFeatures := []database.AffectedFeature{ + { + Namespace: database.Namespace{ + Name: fmt.Sprintf("%s:%s", u.NamespaceName, osVersion), + VersionFormat: rpm.ParserName, + }, + FeatureName: "xfsprogs", + FixedInVersion: "3.2.1-5.1", + AffectedVersion: "3.2.1-5.1", + }, + { + Namespace: database.Namespace{ + Name: fmt.Sprintf("%s:%s", u.NamespaceName, osVersion), + VersionFormat: rpm.ParserName, + }, + FeatureName: "xfsprogs-devel", + FixedInVersion: "3.2.1-5.1", + AffectedVersion: "3.2.1-5.1", + }, + } + + for _, expectedFeature := range expectedFeatures { + assert.Contains(t, vulnerabilities[0].Affected, expectedFeature) + } + } + +} + +func TestSUSEParser(t *testing.T) { + _, filename, _, _ := runtime.Caller(0) + path := filepath.Join(filepath.Dir(filename)) + + // Test parsing testdata/fetcher_opensuse_test.1.xml + testFile, _ := os.Open(path + "/testdata/fetcher_sle_test.1.xml") + defer testFile.Close() + + u := newUpdater(SUSE) + osVersion := "12" + + vulnerabilities, generationTime, err := parseOval(testFile, u.NamespaceName, osVersion) + assert.Nil(t, err) + assert.Equal(t, int64(1467000286), generationTime) + + if assert.Nil(t, err) && assert.Len(t, vulnerabilities, 1) { + assert.Equal(t, "CVE-2012-2150", vulnerabilities[0].Name) + assert.Equal(t, "http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2012-2150", vulnerabilities[0].Link) + assert.Equal(t, `xfs_metadump in xfsprogs before 3.2.4 does not properly obfuscate file data, which allows remote attackers to obtain sensitive information by reading a generated image.`, vulnerabilities[0].Description) + + expectedFeatures := []database.AffectedFeature{ + { + Namespace: database.Namespace{ + Name: fmt.Sprintf("%s:%s", u.NamespaceName, osVersion), + VersionFormat: rpm.ParserName, + }, + FeatureName: "xfsprogs", + FixedInVersion: "3.2.1-3.5", + AffectedVersion: "3.2.1-3.5", + }, + { + Namespace: database.Namespace{ + Name: "sles:12.1", + VersionFormat: rpm.ParserName, + }, + FeatureName: "xfsprogs", + FixedInVersion: "3.2.1-3.5", + AffectedVersion: "3.2.1-3.5", + }, + } + + for _, expectedFeature := range expectedFeatures { + assert.Contains(t, vulnerabilities[0].Affected, expectedFeature) + } + } +} + +func TestPkgInstalledCommentRegexp(t *testing.T) { + testData := map[string][]string{ + "krb5-1.12.1-19.1 is installed": {"krb5", "1.12.1-19.1"}, + "krb5-32bit-1.12.1-19.1 is installed": {"krb5-32bit", "1.12.1-19.1"}, + "krb5-client-1.12.1-19.1 is installed": {"krb5-client", "1.12.1-19.1"}, + "krb5-plugin-kdb-ldap-1.12.1-19.1 is installed": {"krb5-plugin-kdb-ldap", "1.12.1-19.1"}, + "sysvinit-tools-2.88+-96.1 is installed": {"sysvinit-tools", "2.88+-96.1"}, + "ntp-4.2.8p10-63.3 is installed": {"ntp", "4.2.8p10-63.3"}, + "libid3tag0-0.15.1b-182.58 is installed": {"libid3tag0", "0.15.1b-182.58"}, + "libopenssl-devel-1.0.2j-55.1 is installed": {"libopenssl-devel", "1.0.2j-55.1"}, + "libMagickCore-6_Q16-1-6.8.8.1-5.8 is installed": {"libMagickCore-6_Q16-1", "6.8.8.1-5.8"}, + "libGraphicsMagick++-Q16-12-1.3.25-11.44.1 is installed": {"libGraphicsMagick++-Q16-12", "1.3.25-11.44.1"}, + "freerdp-2.0.0~git.1463131968.4e66df7-11.69 is installed": {"freerdp", "2.0.0~git.1463131968.4e66df7-11.69"}, + "libfreerdp2-2.0.0~git.1463131968.4e66df7-11.69 is installed": {"libfreerdp2", "2.0.0~git.1463131968.4e66df7-11.69"}, + "ruby2.1-rubygem-sle2docker-0.2.3-5.1 is installed": {"ruby2.1-rubygem-sle2docker", "0.2.3-5.1"}, + "xen-libs-4.4.1_06-2.2 is installed": {"xen-libs", "4.4.1_06-2.2"}, + "runc-0.1.1+gitr2816_02f8fa7 is installed": {"runc", "0.1.1+gitr2816_02f8fa7"}, + } + + for pkg, expectations := range testData { + name, version, err := splitPackageNameAndVersion(pkg[:len(pkg)-len(" is installed")]) + assert.Nil(t, err) + assert.Equal(t, expectations[0], name) + assert.Equal(t, expectations[1], version) + } + + name, version, err := splitPackageNameAndVersion("invalid-package is installed") + assert.NotNil(t, err) + assert.Empty(t, name) + assert.Empty(t, version) +} diff --git a/ext/vulnsrc/suse/testdata/fetcher_opensuse_test.1.xml b/ext/vulnsrc/suse/testdata/fetcher_opensuse_test.1.xml new file mode 100644 index 00000000..258f8254 --- /dev/null +++ b/ext/vulnsrc/suse/testdata/fetcher_opensuse_test.1.xml @@ -0,0 +1,66 @@ + + + + + Marcus Updateinfo to OVAL Converter + 5.5 + 2016-06-27T04:04:46 + + + + + CVE-2012-2150 + + openSUSE Leap 42.1 + + + xfs_metadump in xfsprogs before 3.2.4 does not properly obfuscate file data, which allows remote attackers to obtain sensitive information by reading a generated image. + + + + + + + + + + + + + + + + + + + + + + + + + + + xfsprogs-devel + + + openSUSE-release + + + xfsprogs + + + + + 0:3.2.1-5.1 + + + 42.1 + + + diff --git a/ext/vulnsrc/suse/testdata/fetcher_sle_test.1.xml b/ext/vulnsrc/suse/testdata/fetcher_sle_test.1.xml new file mode 100644 index 00000000..e718def9 --- /dev/null +++ b/ext/vulnsrc/suse/testdata/fetcher_sle_test.1.xml @@ -0,0 +1,69 @@ + + + + + Marcus Updateinfo to OVAL Converter + 5.5 + 2016-06-27T04:04:46 + + + + + CVE-2012-2150 + + SUSE Linux Enterprise Server 12 + + + xfs_metadump in xfsprogs before 3.2.4 does not properly obfuscate file data, which allows remote attackers to obtain sensitive information by reading a generated image. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sles-release + + + xfsprogs + + + + + 0:3.2.1-3.5 + + + 12 + + + 12.1 + + +