/* Copyright 2021. 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 v1alpha2 import ( "compress/gzip" "encoding/json" "errors" "io" "net/http" "os" "time" "github.com/jenkinsci/kubernetes-operator/pkg/log" "github.com/jenkinsci/kubernetes-operator/pkg/plugins" "golang.org/x/mod/semver" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) var ( jenkinslog = logf.Log.WithName("jenkins-resource") // log is for logging in this package. SecValidator = *NewSecurityValidator() _ webhook.Validator = &Jenkins{} initialSecurityWarningsDownloadSucceded = false ) const ( Hosturl = "https://ci.jenkins.io/job/Infra/job/plugin-site-api/job/generate-data/lastSuccessfulBuild/artifact/plugins.json.gzip" PluginDataFileCompressedPath = "/tmp/plugins.json.gzip" PluginDataFile = "/tmp/plugins.json" shortenedCheckingPeriod = 1 * time.Hour defaultCheckingPeriod = 12 * time.Minute ) func (in *Jenkins) SetupWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr). For(in). Complete() } // TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. // +kubebuilder:webhook:path=/validate-jenkins-io-jenkins-io-v1alpha2-jenkins,mutating=false,failurePolicy=fail,sideEffects=None,groups=jenkins.io.jenkins.io,resources=jenkins,verbs=create;update,versions=v1alpha2,name=vjenkins.kb.io,admissionReviewVersions={v1} // ValidateCreate implements webhook.Validator so a webhook will be registered for the type func (in *Jenkins) ValidateCreate() (admission.Warnings, error) { if in.Spec.ValidateSecurityWarnings { jenkinslog.Info("validate create", "name", in.Name) err := Validate(*in) return nil, err } return nil, nil } // ValidateUpdate implements webhook.Validator so a webhook will be registered for the type func (in *Jenkins) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { if in.Spec.ValidateSecurityWarnings { jenkinslog.Info("validate update", "name", in.Name) return nil, Validate(*in) } return nil, nil } func (in *Jenkins) ValidateDelete() (admission.Warnings, error) { return nil, nil } type SecurityValidator struct { PluginDataCache PluginsInfo isCached bool Attempts int checkingPeriod time.Duration } type PluginsInfo struct { Plugins []PluginInfo `json:"plugins"` } type PluginInfo struct { Name string `json:"name"` SecurityWarnings []Warning `json:"securityWarnings"` } type Warning struct { Versions []Version `json:"versions"` ID string `json:"id"` Message string `json:"message"` URL string `json:"url"` Active bool `json:"active"` } type Version struct { FirstVersion string `json:"firstVersion"` LastVersion string `json:"lastVersion"` } type PluginData struct { Version string Kind string } // Validates security warnings for both updating and creating a Jenkins CR func Validate(r Jenkins) error { if !SecValidator.isCached { return errors.New("plugins data has not been fetched") } pluginSet := make(map[string]PluginData) var faultyBasePlugins string var faultyUserPlugins string basePlugins := plugins.BasePlugins() for _, plugin := range basePlugins { // Only Update the map if the plugin is not present or a lower version is being used if pluginData, ispresent := pluginSet[plugin.Name]; !ispresent || semver.Compare(makeSemanticVersion(plugin.Version), pluginData.Version) == 1 { pluginSet[plugin.Name] = PluginData{Version: plugin.Version, Kind: "base"} } } for _, plugin := range r.Spec.Master.Plugins { if pluginData, ispresent := pluginSet[plugin.Name]; !ispresent || semver.Compare(makeSemanticVersion(plugin.Version), pluginData.Version) == 1 { pluginSet[plugin.Name] = PluginData{Version: plugin.Version, Kind: "user-defined"} } } for _, plugin := range SecValidator.PluginDataCache.Plugins { if pluginData, ispresent := pluginSet[plugin.Name]; ispresent { var hasVulnerabilities bool for _, warning := range plugin.SecurityWarnings { for _, version := range warning.Versions { firstVersion := version.FirstVersion lastVersion := version.LastVersion if len(firstVersion) == 0 { firstVersion = "0" // setting default value in case of empty string } if len(lastVersion) == 0 { lastVersion = pluginData.Version // setting default value in case of empty string } // checking if this warning applies to our version as well if compareVersions(firstVersion, lastVersion, pluginData.Version) { jenkinslog.Info("Security Vulnerability detected in "+pluginData.Kind+" "+plugin.Name+":"+pluginData.Version, "Warning message", warning.Message, "For more details,check security advisory", warning.URL) hasVulnerabilities = true } } } if hasVulnerabilities { if pluginData.Kind == "base" { faultyBasePlugins += "\n" + plugin.Name + ":" + pluginData.Version } else { faultyUserPlugins += "\n" + plugin.Name + ":" + pluginData.Version } } } } if len(faultyBasePlugins) > 0 || len(faultyUserPlugins) > 0 { var errormsg string if len(faultyBasePlugins) > 0 { errormsg += "security vulnerabilities detected in the following base plugins: " + faultyBasePlugins } if len(faultyUserPlugins) > 0 { errormsg += "security vulnerabilities detected in the following user-defined plugins: " + faultyUserPlugins } return errors.New(errormsg) } return nil } // NewMonitor creates a new worker and instantiates all the data structures required func NewSecurityValidator() *SecurityValidator { return &SecurityValidator{ isCached: false, Attempts: 0, checkingPeriod: shortenedCheckingPeriod, } } func (in *SecurityValidator) MonitorSecurityWarnings(securityWarningsFetched chan bool) { jenkinslog.Info("Security warnings check: enabled\n") for { in.checkForSecurityVulnerabilities(securityWarningsFetched) <-time.After(in.checkingPeriod) } } func (in *SecurityValidator) checkForSecurityVulnerabilities(securityWarningsFetched chan bool) { err := in.fetchPluginData() if err != nil { jenkinslog.Info("Cache plugin data", "failed to fetch plugin data", err) in.checkingPeriod = shortenedCheckingPeriod return } in.isCached = true in.checkingPeriod = defaultCheckingPeriod // should only be executed once when the operator starts if !initialSecurityWarningsDownloadSucceded { securityWarningsFetched <- in.isCached initialSecurityWarningsDownloadSucceded = true } } // Downloads extracts and reads the JSON data in every 12 hours func (in *SecurityValidator) fetchPluginData() error { jenkinslog.Info("Initializing/Updating the plugin data cache") var err error for in.Attempts = 0; in.Attempts < 5; in.Attempts++ { err = in.download() if err != nil { jenkinslog.V(log.VDebug).Info("Cache Plugin Data", "failed to download file", err) continue } break } if err != nil { return err } for in.Attempts = 0; in.Attempts < 5; in.Attempts++ { err = in.extract() if err != nil { jenkinslog.V(log.VDebug).Info("Cache Plugin Data", "failed to extract file", err) continue } break } if err != nil { return err } for in.Attempts = 0; in.Attempts < 5; in.Attempts++ { err = in.cache() if err != nil { jenkinslog.V(log.VDebug).Info("Cache Plugin Data", "failed to read plugin data file", err) continue } break } return err } func (in *SecurityValidator) download() error { pluginDataFileCompressed, err := os.Create(PluginDataFileCompressedPath) if err != nil { return err } // ensure pluginDataFileCompressed is closed defer func() { if err := pluginDataFileCompressed.Close(); err != nil { jenkinslog.V(log.VDebug).Info("Failed to close SecurityValidator.download io", "error", err) } }() req, err := http.NewRequest(http.MethodGet, Hosturl, nil) if err != nil { return err } req.Header.Set("Content-Type", "application/json") Client := http.Client{ Timeout: 1 * time.Minute, } response, err := Client.Do(req) if err != nil { return err } defer httpResponseCloser(response) _, err = io.Copy(pluginDataFileCompressed, response.Body) return err } func (in *SecurityValidator) extract() error { reader, err := os.Open(PluginDataFileCompressedPath) if err != nil { return err } defer func() { if err := reader.Close(); err != nil { log.Log.Error(err, "failed to close SecurityValidator.extract.reader ") } }() archive, err := gzip.NewReader(reader) if err != nil { return err } defer func() { if err := archive.Close(); err != nil { log.Log.Error(err, "failed to close SecurityValidator.extract.archive ") } }() writer, err := os.Create(PluginDataFile) if err != nil { return err } defer func() { if err := writer.Close(); err != nil { log.Log.Error(err, "failed to close SecurityValidator.extract.writer") } }() _, err = io.Copy(writer, archive) return err } // Loads the JSON data into memory and stores it func (in *SecurityValidator) cache() error { jsonFile, err := os.Open(PluginDataFile) if err != nil { return err } defer func() { if err := jsonFile.Close(); err != nil { log.Log.Error(err, "failed to close SecurityValidator.cache.jsonFile") } }() byteValue, err := io.ReadAll(jsonFile) if err != nil { return err } err = json.Unmarshal(byteValue, &in.PluginDataCache) return err } // returns a semantic version that can be used for comparison, allowed versioning format vMAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH func makeSemanticVersion(version string) string { if version[0] != 'v' { version = "v" + version } return semver.Canonical(version) } // Compare if the current version lies between first version and last version func compareVersions(firstVersion string, lastVersion string, pluginVersion string) bool { firstSemVer := makeSemanticVersion(firstVersion) lastSemVer := makeSemanticVersion(lastVersion) pluginSemVer := makeSemanticVersion(pluginVersion) if semver.Compare(pluginSemVer, firstSemVer) == -1 || semver.Compare(pluginSemVer, lastSemVer) == 1 { return false } return true } func httpResponseCloser(response *http.Response) { if err := response.Body.Close(); err != nil { log.Log.Error(err, "failed to close http response body") } }