1b9ed99646
Move all transaction related logic to dbutil to simplify and later unify the db interface.
473 lines
13 KiB
Go
473 lines
13 KiB
Go
// 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")
|
|
|
|
// 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 := database.FindKeyValueAndRollback(datastore, 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
|
|
}
|